test(config): add test for consistent config (#4215)

This commit is contained in:
ReenigneArcher 2025-08-31 09:57:03 -04:00 committed by GitHub
commit 14fc19ddbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 722 additions and 27 deletions

8
.github/copilot-instructions.md vendored Normal file
View file

@ -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.

View file

@ -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
},

View file

@ -65,17 +65,17 @@ function removeCmd(index) {
<!-- Log Level -->
<div class="mb-3">
<label for="min_log_level" class="form-label">{{ $t('config.log_level') }}</label>
<label for="min_log_level" class="form-label">{{ $t('config.min_log_level') }}</label>
<select id="min_log_level" class="form-select" v-model="config.min_log_level">
<option value="0">{{ $t('config.log_level_0') }}</option>
<option value="1">{{ $t('config.log_level_1') }}</option>
<option value="2">{{ $t('config.log_level_2') }}</option>
<option value="3">{{ $t('config.log_level_3') }}</option>
<option value="4">{{ $t('config.log_level_4') }}</option>
<option value="5">{{ $t('config.log_level_5') }}</option>
<option value="6">{{ $t('config.log_level_6') }}</option>
<option value="0">{{ $t('config.min_log_level_0') }}</option>
<option value="1">{{ $t('config.min_log_level_1') }}</option>
<option value="2">{{ $t('config.min_log_level_2') }}</option>
<option value="3">{{ $t('config.min_log_level_3') }}</option>
<option value="4">{{ $t('config.min_log_level_4') }}</option>
<option value="5">{{ $t('config.min_log_level_5') }}</option>
<option value="6">{{ $t('config.min_log_level_6') }}</option>
</select>
<div class="form-text">{{ $t('config.log_level_desc') }}</div>
<div class="form-text">{{ $t('config.min_log_level_desc') }}</div>
</div>
<!-- Global Prep Commands -->

View file

@ -65,7 +65,7 @@ function addRemappingEntry() {
<!-- Configuration option -->
<div class="mb-3">
<label for="dd_configuration_option" class="form-label">
{{ $t('config.dd_config_label') }}
{{ $t('config.dd_configuration_option') }}
</label>
<select id="dd_configuration_option" class="form-select" v-model="config.dd_configuration_option">
<option value="disabled">{{ $t('_common.disabled_def') }}</option>
@ -94,7 +94,7 @@ function addRemappingEntry() {
<!-- Manual resolution -->
<div class="mt-2 ps-4" v-if="config.dd_resolution_option === 'manual'">
<div class="form-text">
{{ $t('config.dd_resolution_option_manual_desc') }}
{{ $t('config.dd_manual_resolution') }}
</div>
<input type="text" class="form-control" id="dd_manual_resolution" placeholder="2560x1440"
v-model="config.dd_manual_resolution" />
@ -115,7 +115,7 @@ function addRemappingEntry() {
<!-- Manual refresh rate -->
<div class="mt-2 ps-4" v-if="config.dd_refresh_rate_option === 'manual'">
<div class="form-text">
{{ $t('config.dd_refresh_rate_option_manual_desc') }}
{{ $t('config.dd_manual_refresh_rate') }}
</div>
<input type="text" class="form-control" id="dd_manual_refresh_rate" placeholder="59.9558"
v-model="config.dd_manual_refresh_rate" />

View file

@ -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",

View file

@ -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()

View file

@ -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 <algorithm>
#include <format>
#include <fstream>
#include <map>
#include <ranges>
#include <regex>
#include <set>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
// 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<std::string, std::less<>> extractConfigCppOptions() {
std::set<std::string, std::less<>> 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<typename Container>
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, std::map<std::string, std::string, std::less<>>>) {
container[optionName] = tabId;
} else if constexpr (std::is_same_v<Container, std::map<std::string, std::vector<std::string>, std::less<>>>) {
container[tabId].push_back(optionName);
}
}
}
// Helper function to process tab objects from tabs content
template<typename Container>
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<std::string, std::string, std::less<>> extractConfigHtmlOptions() {
std::map<std::string, std::string, std::less<>> 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::string, std::vector<std::string>, std::less<>> &optionsByTab) {
extractOptionsFromTabGeneric(tabObject, optionsByTab);
}
// Extract config options from config.html with order preserved
static std::map<std::string, std::vector<std::string>, std::less<>> extractConfigHtmlOptionsWithOrder() {
std::map<std::string, std::vector<std::string>, 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 &currentSection) {
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<std::string, std::string, std::less<>> &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<std::string, std::string, std::less<>> extractConfigMdOptions() {
std::map<std::string, std::string, std::less<>> 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 &currentSection, std::map<std::string, std::vector<std::string>, 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::string, std::vector<std::string>, std::less<>> extractConfigMdOptionsWithOrder() {
std::map<std::string, std::vector<std::string>, 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<std::string, std::less<>> &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<std::string, std::less<>> extractEnJsonConfigOptions() {
std::set<std::string, std::less<>> 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<std::string, std::string, std::less<>> expectedDocToTabMapping;
// Helper function to check if an option exists in HTML options
static bool isOptionInHtml(const std::string &option, const std::map<std::string, std::string, std::less<>> &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<std::string, std::string, std::less<>> &mdOptions) {
return mdOptions.contains(option);
}
// Helper function to validate option existence across files
static void validateOptionExistence(const std::string &option, const std::map<std::string, std::string, std::less<>> &htmlOptions, const std::map<std::string, std::string, std::less<>> &mdOptions, const std::set<std::string, std::less<>> &jsonOptions, std::vector<std::string> &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<std::string, std::string, std::less<>> &expectedDocToTabMapping, const std::set<std::string, std::less<>> &mdSections, std::vector<std::string> &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<std::string> &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<std::string> &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<std::string, std::less<>> internalOptions = {
"flags" // Internal config flags, not user-configurable
};
std::vector<std::string> 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<std::string, std::less<>> htmlTabs;
std::set<std::string, std::less<>> mdSections;
for (const auto &tab : htmlOptions | std::views::values) {
htmlTabs.insert(tab);
}
for (const auto &section : mdOptions | std::views::values) {
mdSections.insert(section);
}
std::vector<std::string> 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 &section : 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<std::string> 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<std::string> 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<std::string> 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<std::string> dummyOptions = {
"dummy_config_option",
"nonexistent_setting",
"fake_config_parameter",
"test_dummy_option",
"invalid_config_key"
};
std::vector<std::string> 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<std::string, std::less<>> 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<std::string, std::less<>> internalOptions = {
"flags" // Internal config flags, not user-configurable
};
std::vector<std::string> 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";
}