diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..22f11925 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,8 @@ +On Windows we use msys2 and ucrt64 to compile. +You need to prefix commands with `C:\msys64\msys2_shell.cmd -defterm -here -no-start -ucrt64 -c`. + +Prefix build directories with `cmake-build-`. + +The test executable is named `test_sunshine` + +Always follow the style guidelines defined in .clang-format for c/c++ code. diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 9c1fb5e9..0f7c2ea5 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -147,6 +147,7 @@ "ds4_back_as_touchpad_click": "enabled", "motion_as_ds4": "enabled", "touchpad_as_ds4": "enabled", + "ds5_inputtino_randomize_mac": "enabled", "back_button_timeout": -1, "keyboard": "enabled", "key_repeat_delay": 500, @@ -165,6 +166,7 @@ options: { "audio_sink": "", "virtual_sink": "", + "stream_audio": "enabled", "install_steam_audio_drivers": "enabled", "adapter_name": "", "output_name": "", @@ -174,10 +176,10 @@ "dd_refresh_rate_option": "auto", "dd_manual_refresh_rate": "", "dd_hdr_option": "auto", + "dd_wa_hdr_toggle_delay": 0, "dd_config_revert_delay": 3000, "dd_config_revert_on_disconnect": "disabled", "dd_mode_remapping": {"mixed": [], "resolution_only": [], "refresh_rate_only": []}, - "dd_wa_hdr_toggle_delay": 0, "max_bitrate": 0, "minimum_fps_target": 0 }, diff --git a/src_assets/common/assets/web/configs/tabs/General.vue b/src_assets/common/assets/web/configs/tabs/General.vue index 38c39210..c899d7a4 100644 --- a/src_assets/common/assets/web/configs/tabs/General.vue +++ b/src_assets/common/assets/web/configs/tabs/General.vue @@ -65,17 +65,17 @@ function removeCmd(index) {
- + -
{{ $t('config.log_level_desc') }}
+
{{ $t('config.min_log_level_desc') }}
diff --git a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue index 37945534..4820aa04 100644 --- a/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue +++ b/src_assets/common/assets/web/configs/tabs/audiovideo/DisplayDeviceOptions.vue @@ -65,7 +65,7 @@ function addRemappingEntry() {
@@ -115,7 +115,7 @@ function addRemappingEntry() {
- {{ $t('config.dd_refresh_rate_option_manual_desc') }} + {{ $t('config.dd_manual_refresh_rate') }}
diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index ab3907c1..494b5734 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -155,7 +155,7 @@ "dd_config_ensure_active": "Activate the display automatically", "dd_config_ensure_only_display": "Deactivate other displays and activate only the specified display", "dd_config_ensure_primary": "Activate the display automatically and make it a primary display", - "dd_config_label": "Device configuration", + "dd_configuration_option": "Device configuration", "dd_config_revert_delay": "Config revert delay", "dd_config_revert_delay_desc": "Additional delay in milliseconds to wait before reverting configuration when the app has been closed or the last session terminated. Main purpose is to provide a smoother transition when quickly switching between apps.", "dd_config_revert_on_disconnect": "Config revert on disconnect", @@ -164,6 +164,8 @@ "dd_hdr_option": "HDR", "dd_hdr_option_auto": "Switch on/off the HDR mode as requested by the client (default)", "dd_hdr_option_disabled": "Do not change HDR settings", + "dd_manual_refresh_rate": "Manual refresh rate", + "dd_manual_resolution": "Manual resolution", "dd_mode_remapping": "Display mode remapping", "dd_mode_remapping_add": "Add remapping entry", "dd_mode_remapping_desc_1": "Specify remapping entries to change the requested resolution and/or refresh rate to other values.", @@ -182,12 +184,10 @@ "dd_refresh_rate_option_auto": "Use FPS value provided by the client (default)", "dd_refresh_rate_option_disabled": "Do not change refresh rate", "dd_refresh_rate_option_manual": "Use manually entered refresh rate", - "dd_refresh_rate_option_manual_desc": "Enter the refresh rate to be used", "dd_resolution_option": "Resolution", "dd_resolution_option_auto": "Use resolution provided by the client (default)", "dd_resolution_option_disabled": "Do not change resolution", "dd_resolution_option_manual": "Use manually entered resolution", - "dd_resolution_option_manual_desc": "Enter the resolution to be used", "dd_resolution_option_ogs_desc": "\"Optimize game settings\" option must be enabled on the Moonlight client for this to work.", "dd_wa_hdr_toggle_delay_desc_1": "When using virtual display device (VDD) for streaming, it might incorrectly display HDR color. Sunshine can try to mitigate this issue, by turning HDR off and then on again.", "dd_wa_hdr_toggle_delay_desc_2": "If the value is set to 0, the workaround is disabled (default). If the value is between 0 and 3000 milliseconds, Sunshine will turn off HDR, wait for the specified amount of time and then turn HDR on again. The recommended delay time is around 500 milliseconds in most cases.", @@ -238,6 +238,7 @@ "key_repeat_frequency_desc": "How often keys repeat every second. This configurable option supports decimals.", "key_rightalt_to_key_win": "Map Right Alt key to Windows key", "key_rightalt_to_key_win_desc": "It may be possible that you cannot send the Windows Key from Moonlight directly. In those cases it may be useful to make Sunshine think the Right Alt key is the Windows key", + "keybindings": "Keybindings", "keyboard": "Enable Keyboard Input", "keyboard_desc": "Allows guests to control the host system with the keyboard", "lan_encryption_mode": "LAN Encryption Mode", @@ -246,21 +247,21 @@ "lan_encryption_mode_desc": "This determines when encryption will be used when streaming over your local network. Encryption can reduce streaming performance, particularly on less powerful hosts and clients.", "locale": "Locale", "locale_desc": "The locale used for Sunshine's user interface.", - "log_level": "Log Level", - "log_level_0": "Verbose", - "log_level_1": "Debug", - "log_level_2": "Info", - "log_level_3": "Warning", - "log_level_4": "Error", - "log_level_5": "Fatal", - "log_level_6": "None", - "log_level_desc": "The minimum log level printed to standard out", "log_path": "Logfile Path", "log_path_desc": "The file where the current logs of Sunshine are stored.", "max_bitrate": "Maximum Bitrate", "max_bitrate_desc": "The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always use the bitrate requested by Moonlight.", "minimum_fps_target": "Minimum FPS Target", "minimum_fps_target_desc": "The lowest effective FPS a stream can reach. A value of 0 is treated as roughly half of the stream's FPS. A setting of 20 is recommended if you stream 24 or 30fps content.", + "min_log_level": "Log Level", + "min_log_level_0": "Verbose", + "min_log_level_1": "Debug", + "min_log_level_2": "Info", + "min_log_level_3": "Warning", + "min_log_level_4": "Error", + "min_log_level_5": "Fatal", + "min_log_level_6": "None", + "min_log_level_desc": "The minimum log level printed to standard out", "min_threads": "Minimum CPU Thread Count", "min_threads_desc": "Increasing the value slightly reduces encoding efficiency, but the tradeoff is usually worth it to gain the use of more CPU cores for encoding. The ideal value is the lowest value that can reliably encode at your desired streaming settings on your hardware.", "misc": "Miscellaneous options", @@ -298,10 +299,9 @@ "origin_web_ui_allowed_lan": "Only those in LAN may access Web UI", "origin_web_ui_allowed_pc": "Only localhost may access Web UI", "origin_web_ui_allowed_wan": "Anyone may access Web UI", + "output_name": "Display Id", "output_name_desc_unix": "During Sunshine startup, you should see the list of detected displays. Note: You need to use the id value inside the parenthesis. Below is an example; the actual output can be found in the Troubleshooting tab.", "output_name_desc_windows": "Manually specify a display device id to use for capture. If unset, the primary display is captured. Note: If you specified a GPU above, this display must be connected to that GPU. During Sunshine startup, you should see the list of detected displays. Below is an example; the actual output can be found in the Troubleshooting tab.", - "output_name_unix": "Display number", - "output_name_windows": "Display Device Id", "ping_timeout": "Ping Timeout", "ping_timeout_desc": "How long to wait in milliseconds for data from moonlight before shutting down the stream", "pkey": "Private Key", diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b15a53fd..cc4515dd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,24 @@ add_executable(${PROJECT_NAME} ${TEST_SOURCES} ${SUNSHINE_SOURCES}) +# Copy files needed for config consistency tests to build directory +# This ensures both CLI and CLion can access the same files relative to the test executable +# Using configure_file ensures files are copied when they change between builds +set(CONFIG_TEST_FILES + "src/config.cpp" + "src_assets/common/assets/web/config.html" + "docs/configuration.md" + "src_assets/common/assets/web/public/assets/locale/en.json" +) + +foreach(file ${CONFIG_TEST_FILES}) + configure_file( + "${CMAKE_SOURCE_DIR}/${file}" + "${CMAKE_CURRENT_BINARY_DIR}/${file}" + COPYONLY + ) +endforeach() + foreach(dep ${SUNSHINE_TARGET_DEPENDENCIES}) add_dependencies(${PROJECT_NAME} ${dep}) # compile these before sunshine endforeach() diff --git a/tests/integration/test_config_consistency.cpp b/tests/integration/test_config_consistency.cpp new file mode 100644 index 00000000..01e349b0 --- /dev/null +++ b/tests/integration/test_config_consistency.cpp @@ -0,0 +1,667 @@ +/** + * @file tests/integration/test_config_consistency.cpp + * @brief Test configuration consistency across all configuration files + */ +#include "../tests_common.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/file_handler.h" + +class ConfigConsistencyTest: public ::testing::Test { +protected: + void SetUp() override { + // Define the expected mapping between documentation sections and UI tabs + expectedDocToTabMapping = { + {"General", "general"}, + {"Input", "input"}, + {"Audio/Video", "av"}, + {"Network", "network"}, + {"Config Files", "files"}, + {"Advanced", "advanced"}, + {"NVIDIA NVENC Encoder", "nv"}, + {"Intel QuickSync Encoder", "qsv"}, + {"AMD AMF Encoder", "amd"}, + {"VideoToolbox Encoder", "vt"}, + {"VA-API Encoder", "vaapi"}, + {"Software Encoder", "sw"} + }; + } + + // Extract config options from config.cpp - the authoritative source + static std::set> extractConfigCppOptions() { + std::set> options; + std::string content = file_handler::read_file("src/config.cpp"); + + // Regex patterns to match different config option types in config.cpp + const std::vector patterns = { + std::regex(R"DELIM((?:string_f|path_f|string_restricted_f)\s*\(\s*vars\s*,\s*"([^"]+)")DELIM"), + std::regex(R"DELIM((?:int_f|int_between_f)\s*\(\s*vars\s*,\s*"([^"]+)")DELIM"), + std::regex(R"DELIM(bool_f\s*\(\s*vars\s*,\s*"([^"]+)")DELIM"), + std::regex(R"DELIM((?:double_f|double_between_f)\s*\(\s*vars\s*,\s*"([^"]+)")DELIM"), + std::regex(R"DELIM(generic_f\s*\(\s*vars\s*,\s*"([^"]+)")DELIM"), + std::regex(R"DELIM(list_prep_cmd_f\s*\(\s*vars\s*,\s*"([^"]+)")DELIM"), + std::regex(R"DELIM(map_int_int_f\s*\(\s*vars\s*,\s*"([^"]+)")DELIM") + }; + + for (const auto &pattern : patterns) { + std::sregex_iterator iter(content.begin(), content.end(), pattern); + + for (std::sregex_iterator end; iter != end; ++iter) { + std::string optionName = (*iter)[1].str(); + options.insert(optionName); + } + } + + return options; + } + + // Helper function to find brace boundaries + static size_t findClosingBrace(const std::string &content, const size_t start) { + size_t pos = start + 1; + int braceLevel = 1; + + while (pos < content.length() && braceLevel > 0) { + if (content[pos] == '{') { + braceLevel++; + } else if (content[pos] == '}') { + braceLevel--; + } + pos++; + } + + return pos - 1; + } + + // Helper function to extract tab ID from a tab object + static std::string extractTabId(const std::string &tabObject) { + const std::regex idPattern(R"DELIM(id:\s*"([^"]+)")DELIM"); + + if (std::smatch idMatch; std::regex_search(tabObject, idMatch, idPattern)) { + return idMatch[1].str(); + } + + return ""; + } + + // Helper function to find and extract tabs content from HTML + static std::string extractTabsContent(const std::string &content) { + const size_t tabsStart = content.find("tabs: ["); + if (tabsStart == std::string::npos) { + return ""; + } + + // Find the end of the tab array + size_t pos = tabsStart + 7; // Skip "tabs: [" + int bracketLevel = 1; + size_t tabsEnd = pos; + + while (pos < content.length() && bracketLevel > 0) { + if (content[pos] == '[') { + bracketLevel++; + } else if (content[pos] == ']') { + bracketLevel--; + } + tabsEnd = pos; + pos++; + } + + return content.substr(tabsStart + 7, tabsEnd - tabsStart - 7); + } + + // Helper function to extract options from a tab object (generic version) + template + static void extractOptionsFromTabGeneric(const std::string &tabObject, Container &container) { + const std::string tabId = extractTabId(tabObject); + if (tabId.empty()) { + return; + } + + const size_t optionsStart = tabObject.find("options:"); + if (optionsStart == std::string::npos) { + return; + } + + const size_t optStart = tabObject.find('{', optionsStart); + if (optStart == std::string::npos) { + return; + } + + const size_t optEnd = findClosingBrace(tabObject, optStart); + std::string optionsSection = tabObject.substr(optStart + 1, optEnd - optStart - 1); + + // Extract option names + const std::regex optionPattern(R"DELIM("([^"]+)":\s*)DELIM"); + std::sregex_iterator optionIter(optionsSection.begin(), optionsSection.end(), optionPattern); + + for (const std::sregex_iterator optionEnd; optionIter != optionEnd; ++optionIter) { + std::string optionName = (*optionIter)[1].str(); + + // Use if constexpr to handle different container types + if constexpr (std::is_same_v>>) { + container[optionName] = tabId; + } else if constexpr (std::is_same_v, std::less<>>>) { + container[tabId].push_back(optionName); + } + } + } + + // Helper function to process tab objects from tabs content + template + static void processTabObjects(const std::string &tabsContent, Container &container) { + size_t tabPos = 0; + while (tabPos < tabsContent.length()) { + const size_t objStart = tabsContent.find('{', tabPos); + if (objStart == std::string::npos) { + break; + } + + const size_t objEnd = findClosingBrace(tabsContent, objStart); + std::string tabObject = tabsContent.substr(objStart, objEnd - objStart + 1); + + extractOptionsFromTabGeneric(tabObject, container); + + tabPos = objEnd + 1; + } + } + + // Helper function to trim whitespace from string + static void trimWhitespace(std::string &str) { + str.erase(str.find_last_not_of(" \t\r\n") + 1); + } + + // Helper function to extract option name from the Markdown line + static std::string extractOptionFromMarkdownLine(const std::string &line) { + const std::regex optionPattern(R"(^### ([^#\r\n]+))"); + if (std::smatch optionMatch; std::regex_search(line, optionMatch, optionPattern)) { + std::string optionName = optionMatch[1].str(); + trimWhitespace(optionName); + return optionName; + } + return ""; + } + + // Extract config options from config.html + static std::map> extractConfigHtmlOptions() { + std::map> options; + const std::string content = file_handler::read_file("src_assets/common/assets/web/config.html"); + + const std::string tabsContent = extractTabsContent(content); + if (tabsContent.empty()) { + return options; + } + + processTabObjects(tabsContent, options); + return options; + } + + // Helper function to extract options from a single tab object (now using generic function) + static void extractOptionsFromTab(const std::string &tabObject, std::map, std::less<>> &optionsByTab) { + extractOptionsFromTabGeneric(tabObject, optionsByTab); + } + + // Extract config options from config.html with order preserved + static std::map, std::less<>> extractConfigHtmlOptionsWithOrder() { + std::map, std::less<>> optionsByTab; + const std::string content = file_handler::read_file("src_assets/common/assets/web/config.html"); + + const std::string tabsContent = extractTabsContent(content); + if (tabsContent.empty()) { + return optionsByTab; + } + + processTabObjects(tabsContent, optionsByTab); + return optionsByTab; + } + + // Helper function to process markdown line for section headers + static bool processSectionHeader(const std::string &line, std::string ¤tSection) { + const std::regex sectionPattern(R"(^## ([^#\r\n]+))"); + + if (std::smatch sectionMatch; std::regex_search(line, sectionMatch, sectionPattern)) { + currentSection = sectionMatch[1].str(); + trimWhitespace(currentSection); + return true; + } + + return false; + } + + // Helper function to process markdown line for option headers + static bool processOptionHeader(const std::string &line, const std::string_view currentSection, std::map> &options) { + if (currentSection.empty()) { + return false; + } + + if (const std::string optionName = extractOptionFromMarkdownLine(line); !optionName.empty()) { + options[optionName] = currentSection; + return true; + } + + return false; + } + + // Extract config options from configuration.md + static std::map> extractConfigMdOptions() { + std::map> options; + const std::string content = file_handler::read_file("docs/configuration.md"); + + std::istringstream stream(content); + std::string line; + std::string currentSection; + + while (std::getline(stream, line)) { + if (processSectionHeader(line, currentSection)) { + continue; + } + + processOptionHeader(line, currentSection, options); + } + + return options; + } + + // Helper function to process markdown option line for order-preserved extraction + static void processMarkdownOptionLine(const std::string &line, const std::string ¤tSection, std::map, std::less<>> &optionsBySection) { + if (currentSection.empty()) { + return; + } + + if (const std::string optionName = extractOptionFromMarkdownLine(line); !optionName.empty()) { + optionsBySection[currentSection].push_back(optionName); + } + } + + // Extract config options from configuration.md with order preserved + static std::map, std::less<>> extractConfigMdOptionsWithOrder() { + std::map, std::less<>> optionsBySection; + const std::string content = file_handler::read_file("docs/configuration.md"); + + std::istringstream stream(content); + std::string line; + std::string currentSection; + + while (std::getline(stream, line)) { + if (processSectionHeader(line, currentSection)) { + continue; + } + + processMarkdownOptionLine(line, currentSection, optionsBySection); + } + + return optionsBySection; + } + + // Helper function to find the config section end + static size_t findConfigSectionEnd(const std::string &content, size_t configStart) { + size_t braceCount = 1; + size_t configEnd = configStart; + + while (configStart < content.length() && braceCount > 0) { + if (content[configStart] == '{') { + braceCount++; + } else if (content[configStart] == '}') { + braceCount--; + } + configEnd = configStart; + configStart++; + } + + return configEnd; + } + + // Helper function to extract keys from a config section + static void extractKeysFromConfigSection(const std::string_view configSection, std::set> &options) { + const std::regex keyPattern(R"DELIM("([^"]+)":\s*)DELIM"); + std::string configStr(configSection); + std::sregex_iterator iter(configStr.begin(), configStr.end(), keyPattern); + + for (const std::sregex_iterator end; iter != end; ++iter) { + options.insert((*iter)[1].str()); + } + } + + // Extract config options from en.json + static std::set> extractEnJsonConfigOptions() { + std::set> options; + const std::string content = file_handler::read_file("src_assets/common/assets/web/public/assets/locale/en.json"); + + // Look for the config section + const std::regex configSectionPattern(R"DELIM("config":\s*\{)DELIM"); + std::smatch match; + + if (!std::regex_search(content, match, configSectionPattern)) { + return options; + } + + // Find the config section and extract keys + const size_t configStart = match.position() + match.length(); + const size_t configEnd = findConfigSectionEnd(content, configStart); + const std::string configSection = content.substr(configStart, configEnd - configStart); + + extractKeysFromConfigSection(configSection, options); + + return options; + } + + std::map> expectedDocToTabMapping; + + // Helper function to check if an option exists in HTML options + static bool isOptionInHtml(const std::string &option, const std::map> &htmlOptions) { + return htmlOptions.contains(option); + } + + // Helper function to check if an option exists in MD options + static bool isOptionInMd(const std::string &option, const std::map> &mdOptions) { + return mdOptions.contains(option); + } + + // Helper function to validate option existence across files + static void validateOptionExistence(const std::string &option, const std::map> &htmlOptions, const std::map> &mdOptions, const std::set> &jsonOptions, std::vector &missingFromFiles) { + if (!isOptionInHtml(option, htmlOptions)) { + missingFromFiles.push_back(std::format("config.html missing: {}", option)); + } + + if (!isOptionInMd(option, mdOptions)) { + missingFromFiles.push_back(std::format("configuration.md missing: {}", option)); + } + + if (!jsonOptions.contains(option)) { + missingFromFiles.push_back(std::format("en.json missing: {}", option)); + } + } + + // Helper function to check tab correspondence with documentation sections + static void checkTabCorrespondence(const std::string &tab, const std::map> &expectedDocToTabMapping, const std::set> &mdSections, std::vector &inconsistencies) { + bool found = false; + + for (const auto &[docSection, expectedTab] : expectedDocToTabMapping) { + if (expectedTab != tab) { + continue; + } + + if (!mdSections.contains(docSection)) { + inconsistencies.push_back(std::format("Tab '{}' maps to doc section '{}' but section not found", tab, docSection)); + } + found = true; + break; + } + + if (!found) { + inconsistencies.push_back(std::format("Tab '{}' has no corresponding documentation section", tab)); + } + } + + // Helper function to check if a test fake option is found in missing files + static void checkTestDummyDetection(const std::vector &missingFromFiles, const std::string &testDummyOption, bool &foundMissingDummyInHtml, bool &foundMissingDummyInMd, bool &foundMissingDummyInJson) { + for (const auto &missing : missingFromFiles) { + if (!missing.contains(testDummyOption)) { + continue; + } + + if (missing.contains("config.html")) { + foundMissingDummyInHtml = true; + } + if (missing.contains("configuration.md")) { + foundMissingDummyInMd = true; + } + if (missing.contains("en.json")) { + foundMissingDummyInJson = true; + } + } + } + + // Helper function to create comma-separated string from vector + static std::string buildCommaSeparatedString(const std::vector &options) { + std::string result; + for (size_t i = 0; i < options.size(); ++i) { + if (i > 0) { + result += ", "; + } + result += options[i]; + } + return result; + } +}; + +TEST_F(ConfigConsistencyTest, AllConfigOptionsExistInAllFiles) { + const auto cppOptions = extractConfigCppOptions(); + const auto htmlOptions = extractConfigHtmlOptions(); + const auto mdOptions = extractConfigMdOptions(); + const auto jsonOptions = extractEnJsonConfigOptions(); + + // Options that are internal/special and shouldn't be in UI/docs + const std::set> internalOptions = { + "flags" // Internal config flags, not user-configurable + }; + + std::vector missingFromFiles; + + // Check that all config.cpp options exist in other files (except internal ones) + for (const auto &option : cppOptions) { + if (internalOptions.contains(option)) { + continue; // Skip internal options + } + + validateOptionExistence(option, htmlOptions, mdOptions, jsonOptions, missingFromFiles); + } + + if (!missingFromFiles.empty()) { + std::string errorMsg = "Config options missing from files:\n"; + for (const auto &missing : missingFromFiles) { + errorMsg += std::format(" {}\n", missing); + } + FAIL() << errorMsg; + } +} + +TEST_F(ConfigConsistencyTest, ConfigTabsMatchDocumentationSections) { + auto htmlOptions = extractConfigHtmlOptions(); + auto mdOptions = extractConfigMdOptions(); + + // Get unique tabs and sections + std::set> htmlTabs; + std::set> mdSections; + + for (const auto &tab : htmlOptions | std::views::values) { + htmlTabs.insert(tab); + } + + for (const auto §ion : mdOptions | std::views::values) { + mdSections.insert(section); + } + + std::vector inconsistencies; + + // Check that each HTML tab has a corresponding documentation section + for (const auto &tab : htmlTabs) { + checkTabCorrespondence(tab, expectedDocToTabMapping, mdSections, inconsistencies); + } + + // Check that each documentation section has a corresponding HTML tab + for (const auto §ion : mdSections) { + if (!expectedDocToTabMapping.contains(section)) { + inconsistencies.push_back(std::format("Documentation section '{}' has no corresponding UI tab", section)); + } + } + + if (!inconsistencies.empty()) { + std::string errorMsg = "Tab/Section mapping inconsistencies:\n"; + for (const auto &inconsistency : inconsistencies) { + errorMsg += std::format(" {}\n", inconsistency); + } + FAIL() << errorMsg; + } +} + +TEST_F(ConfigConsistencyTest, ConfigOptionsInSameOrderWithinSections) { + // Extract options with order preserved + auto htmlOptionsByTab = extractConfigHtmlOptionsWithOrder(); + auto mdOptionsBySection = extractConfigMdOptionsWithOrder(); + + std::vector orderInconsistencies; + + // Compare order for each tab/section pair + for (const auto &[docSection, tabId] : expectedDocToTabMapping) { + if (!htmlOptionsByTab.contains(tabId) || !mdOptionsBySection.contains(docSection)) { + continue; // Skip if either tab or section doesn't exist + } + + const auto &htmlOrder = htmlOptionsByTab.at(tabId); + const auto &mdOrder = mdOptionsBySection.at(docSection); + + // Find options that exist in both HTML and MD for this section + std::vector commonOptions; + for (const auto &option : htmlOrder) { + if (std::ranges::find(mdOrder, option) != mdOrder.end()) { + commonOptions.push_back(option); + } + } + + // Filter MD order to only include common options in the same order they appear in MD + std::vector mdOrderFiltered; + for (const auto &option : mdOrder) { + if (std::ranges::find(commonOptions, option) != commonOptions.end()) { + mdOrderFiltered.push_back(option); + } + } + + // Compare the order of common options + if (commonOptions != mdOrderFiltered && !commonOptions.empty() && !mdOrderFiltered.empty()) { + // Create readable string representations of the option lists + std::string htmlOrderStr = buildCommaSeparatedString(commonOptions); + std::string mdOrderStr = buildCommaSeparatedString(mdOrderFiltered); + + std::string detailMsg = std::format( + "Section '{}' (tab '{}') has different option order:\n" + " HTML order: [{}]\n" + " MD order: [{}]", + docSection, + tabId, + htmlOrderStr, + mdOrderStr + ); + orderInconsistencies.push_back(detailMsg); + } + } + + if (!orderInconsistencies.empty()) { + std::string errorMsg = "Config option order inconsistencies:\n"; + for (const auto &inconsistency : orderInconsistencies) { + errorMsg += std::format(" {}\n", inconsistency); + } + FAIL() << errorMsg; + } +} + +TEST_F(ConfigConsistencyTest, DummyConfigOptionsDoNotExist) { + const auto cppOptions = extractConfigCppOptions(); + const auto htmlOptions = extractConfigHtmlOptions(); + const auto mdOptions = extractConfigMdOptions(); + const auto jsonOptions = extractEnJsonConfigOptions(); + + // List of fake config options that should NOT exist in any files + const std::vector dummyOptions = { + "dummy_config_option", + "nonexistent_setting", + "fake_config_parameter", + "test_dummy_option", + "invalid_config_key" + }; + + std::vector unexpectedlyFound; + + // Check that none of the fake options exist in any of the config files + for (const auto &dummyOption : dummyOptions) { + if (cppOptions.contains(dummyOption)) { + unexpectedlyFound.push_back(std::format("config.cpp contains dummy option: {}", dummyOption)); + } + + if (htmlOptions.contains(dummyOption)) { + unexpectedlyFound.push_back(std::format("config.html contains dummy option: {}", dummyOption)); + } + + if (mdOptions.contains(dummyOption)) { + unexpectedlyFound.push_back(std::format("configuration.md contains dummy option: {}", dummyOption)); + } + + if (jsonOptions.contains(dummyOption)) { + unexpectedlyFound.push_back(std::format("en.json contains dummy option: {}", dummyOption)); + } + } + + // This test should pass (i.e., no fake options should be found) + // If any fake options are found, it indicates a problem with the test data + if (!unexpectedlyFound.empty()) { + std::string errorMsg = "Dummy config options unexpectedly found in files:\n"; + for (const auto &found : unexpectedlyFound) { + errorMsg += std::format(" {}\n", found); + } + FAIL() << errorMsg; + } +} + +TEST_F(ConfigConsistencyTest, TestFrameworkDetectsMissingOptions) { + const auto cppOptions = extractConfigCppOptions(); + const auto htmlOptions = extractConfigHtmlOptions(); + const auto mdOptions = extractConfigMdOptions(); + const auto jsonOptions = extractEnJsonConfigOptions(); + + // Add a fake option to the cpp options to simulate a missing option scenario + std::set> modifiedCppOptions = cppOptions; + const std::string testDummyOption = "test_framework_validation_option"; + modifiedCppOptions.insert(testDummyOption); + + // Options that are internal/special and shouldn't be in UI/docs + std::set> internalOptions = { + "flags" // Internal config flags, not user-configurable + }; + + std::vector missingFromFiles; + + // Check that the fake option is detected as missing from other files + for (const auto &option : modifiedCppOptions) { + if (internalOptions.contains(option)) { + continue; // Skip internal options + } + + if (!htmlOptions.contains(option)) { + missingFromFiles.push_back(std::format("config.html missing: {}", option)); + } + + if (!mdOptions.contains(option)) { + missingFromFiles.push_back(std::format("configuration.md missing: {}", option)); + } + + if (!jsonOptions.contains(option)) { + missingFromFiles.push_back(std::format("en.json missing: {}", option)); + } + } + + // Verify that the test framework detected the missing fake option + bool foundMissingDummyInHtml = false; + bool foundMissingDummyInMd = false; + bool foundMissingDummyInJson = false; + + checkTestDummyDetection(missingFromFiles, testDummyOption, foundMissingDummyInHtml, foundMissingDummyInMd, foundMissingDummyInJson); + + // The test framework should have detected the fake option as missing from all files + EXPECT_TRUE(foundMissingDummyInHtml) << "Test framework failed to detect missing option in config.html"; + EXPECT_TRUE(foundMissingDummyInMd) << "Test framework failed to detect missing option in configuration.md"; + EXPECT_TRUE(foundMissingDummyInJson) << "Test framework failed to detect missing option in en.json"; + + // Verify we have at least 3 missing entries (one for each file type) + EXPECT_GE(missingFromFiles.size(), 3) << "Test framework should detect missing dummy option in all three file types"; +}