diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 22f11925..7b056d2a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,6 +3,9 @@ You need to prefix commands with `C:\msys64\msys2_shell.cmd -defterm -here -no-s Prefix build directories with `cmake-build-`. -The test executable is named `test_sunshine` +The test executable is named `test_sunshine` and will be located inside the `tests` directory within +the build directory. + +The project uses gtest as a test framework. Always follow the style guidelines defined in .clang-format for c/c++ code. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cc4515dd..f0fdcaae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,6 +60,7 @@ set(CONFIG_TEST_FILES "src_assets/common/assets/web/config.html" "docs/configuration.md" "src_assets/common/assets/web/public/assets/locale/en.json" + "src_assets/common/assets/web/configs/tabs/General.vue" ) foreach(file ${CONFIG_TEST_FILES}) @@ -70,10 +71,25 @@ foreach(file ${CONFIG_TEST_FILES}) ) endforeach() +# Copy all locale files for locale consistency tests +# Use a custom command to properly handle both adding and removing files +set(LOCALE_SRC_DIR "${CMAKE_SOURCE_DIR}/src_assets/common/assets/web/public/assets/locale") +set(LOCALE_DST_DIR "${CMAKE_CURRENT_BINARY_DIR}/src_assets/common/assets/web/public/assets/locale") +add_custom_target(sync_locale_files ALL + COMMAND ${CMAKE_COMMAND} -E rm -rf "${LOCALE_DST_DIR}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${LOCALE_DST_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${LOCALE_SRC_DIR}" "${LOCALE_DST_DIR}" + COMMENT "Synchronizing locale files for tests" + VERBATIM +) + foreach(dep ${SUNSHINE_TARGET_DEPENDENCIES}) add_dependencies(${PROJECT_NAME} ${dep}) # compile these before sunshine endforeach() +# Ensure locale files are synchronized before building the test executable +add_dependencies(${PROJECT_NAME} sync_locale_files) + set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 23) target_link_libraries(${PROJECT_NAME} ${SUNSHINE_EXTERNAL_LIBRARIES} diff --git a/tests/integration/test_locale_consistency.cpp b/tests/integration/test_locale_consistency.cpp new file mode 100644 index 00000000..3fdc7ef4 --- /dev/null +++ b/tests/integration/test_locale_consistency.cpp @@ -0,0 +1,357 @@ +/** + * @file tests/integration/test_locale_consistency.cpp + * @brief Test locale consistency across configuration files and locale JSON files + */ +#include "../tests_common.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include + +// local includes +#include "src/file_handler.h" + +namespace fs = std::filesystem; + +class LocaleConsistencyTest: public ::testing::Test { +protected: + // Extract locale options from config.cpp + static std::set> extractConfigCppLocales() { + std::set> locales; + const std::string content = file_handler::read_file("src/config.cpp"); + + // Find the string_restricted_f call for locale + const std::regex localeSection(R"(string_restricted_f\s*\(\s*vars\s*,\s*"locale"[^}]*\{([^}]*)\})"); + + if (std::smatch match; std::regex_search(content, match, localeSection)) { + const std::string localeList = match[1].str(); + + // Extract individual locale codes + const std::regex localePattern(R"delimiter("([^"]+)"sv)delimiter"); + std::sregex_iterator iter(localeList.begin(), localeList.end(), localePattern); + + for (const std::sregex_iterator end; iter != end; ++iter) { + locales.insert((*iter)[1].str()); + } + } + + return locales; + } + + // Extract locale options from General.vue + static std::map> extractGeneralVueLocales() { + std::map> locales; + const std::string content = file_handler::read_file("src_assets/common/assets/web/configs/tabs/General.vue"); + + // Find the locale select section specifically + const std::regex localeSelectPattern("id=\"locale\"[^>]*>([^<]*(?:]*>[^<]*[^<]*)*)"); + + if (std::smatch selectMatch; std::regex_search(content, selectMatch, localeSelectPattern)) { + const std::string localeSection = selectMatch[1].str(); + + // Extract option elements with locale codes and display names from the locale section + const std::regex optionPattern(R"delimiter(([^<]+))delimiter"); + std::sregex_iterator iter(localeSection.begin(), localeSection.end(), optionPattern); + + for (const std::sregex_iterator end; iter != end; ++iter) { + const std::string localeCode = (*iter)[1].str(); + const std::string displayName = (*iter)[2].str(); + locales[localeCode] = displayName; + } + } + + return locales; + } + + // Get available locale JSON files + static std::set> getAvailableLocaleFiles() { + std::set> locales; + const std::filesystem::path localeDir = "src_assets/common/assets/web/public/assets/locale"; + + if (!fs::exists(localeDir)) { + return locales; + } + + for (const auto &entry : fs::directory_iterator(localeDir)) { + if (entry.is_regular_file() && entry.path().extension() == ".json") { + const std::string filename = entry.path().stem().string(); + locales.insert(filename); + } + } + + return locales; + } + + // Helper function to check if a locale JSON file is valid using nlohmann/json + static bool isValidLocaleFile(const std::string &localeCode) { + const std::string filePath = std::format("src_assets/common/assets/web/public/assets/locale/{}.json", localeCode); + + if (!fs::exists(filePath)) { + return false; + } + + try { + const std::string content = file_handler::read_file(filePath.c_str()); + + // Parse JSON using nlohmann/json to validate it's properly formatted + const nlohmann::json localeJson = nlohmann::json::parse(content); + + // Basic validation - should be a JSON object with some content + return localeJson.is_object() && !localeJson.empty(); + } catch (const nlohmann::json::parse_error &) { + return false; + } + } +}; + +TEST_F(LocaleConsistencyTest, AllLocaleFilesHaveConfigCppEntries) { + const auto configLocales = extractConfigCppLocales(); + const auto localeFiles = getAvailableLocaleFiles(); + + std::vector missingFromConfig; + + // Check that every locale file has a corresponding entry in config.cpp + for (const auto &localeFile : localeFiles) { + if (!configLocales.contains(localeFile)) { + missingFromConfig.push_back(localeFile); + } + } + + if (!missingFromConfig.empty()) { + std::string errorMsg = "Locale files missing from config.cpp:\n"; + for (const auto &missing : missingFromConfig) { + errorMsg += std::format(" {}.json\n", missing); + } + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, AllLocaleFilesHaveGeneralVueEntries) { + const auto vueLocales = extractGeneralVueLocales(); + const auto localeFiles = getAvailableLocaleFiles(); + + std::vector missingFromVue; + + // Check that every locale file has a corresponding entry in General.vue + for (const auto &localeFile : localeFiles) { + if (!vueLocales.contains(localeFile)) { + missingFromVue.push_back(localeFile); + } + } + + if (!missingFromVue.empty()) { + std::string errorMsg = "Locale files missing from General.vue:\n"; + for (const auto &missing : missingFromVue) { + errorMsg += std::format(" {}.json\n", missing); + } + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, AllConfigCppLocalesHaveFiles) { + const auto configLocales = extractConfigCppLocales(); + const auto localeFiles = getAvailableLocaleFiles(); + + std::vector missingFiles; + + // Check that every config.cpp locale has a corresponding JSON file + for (const auto &configLocale : configLocales) { + if (!localeFiles.contains(configLocale)) { + missingFiles.push_back(configLocale); + } + } + + if (!missingFiles.empty()) { + std::string errorMsg = "config.cpp locales missing JSON files:\n"; + for (const auto &missing : missingFiles) { + errorMsg += std::format(" {}.json\n", missing); + } + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, AllGeneralVueLocalesHaveFiles) { + const auto vueLocales = extractGeneralVueLocales(); + const auto localeFiles = getAvailableLocaleFiles(); + + std::vector missingFiles; + + // Check that every General.vue locale has a corresponding JSON file + for (const auto &vueLocale : vueLocales | std::views::keys) { + if (!localeFiles.contains(vueLocale)) { + missingFiles.push_back(vueLocale); + } + } + + if (!missingFiles.empty()) { + std::string errorMsg = "General.vue locales missing JSON files:\n"; + for (const auto &missing : missingFiles) { + errorMsg += std::format(" {}.json\n", missing); + } + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, ConfigCppAndGeneralVueLocalesMatch) { + const auto configLocales = extractConfigCppLocales(); + const auto vueLocales = extractGeneralVueLocales(); + + std::vector configOnlyLocales; + std::vector vueOnlyLocales; + + // Find locales in config.cpp but not in General.vue + for (const auto &configLocale : configLocales) { + if (!vueLocales.contains(configLocale)) { + configOnlyLocales.push_back(configLocale); + } + } + + // Find locales in General.vue but not in config.cpp + for (const auto &vueLocale : vueLocales | std::views::keys) { + if (!configLocales.contains(vueLocale)) { + vueOnlyLocales.push_back(vueLocale); + } + } + + std::string errorMsg; + + if (!configOnlyLocales.empty()) { + errorMsg += "Locales in config.cpp but not in General.vue:\n"; + for (const auto &locale : configOnlyLocales) { + errorMsg += std::format(" {}\n", locale); + } + } + + if (!vueOnlyLocales.empty()) { + errorMsg += "Locales in General.vue but not in config.cpp:\n"; + for (const auto &locale : vueOnlyLocales) { + errorMsg += std::format(" {}\n", locale); + } + } + + if (!errorMsg.empty()) { + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, AllLocaleFilesAreValid) { + const auto localeFiles = getAvailableLocaleFiles(); + std::vector invalidFiles; + + // Check that all locale files are valid JSON + for (const auto &localeFile : localeFiles) { + if (!isValidLocaleFile(localeFile)) { + invalidFiles.push_back(localeFile); + } + } + + if (!invalidFiles.empty()) { + std::string errorMsg = "Invalid locale files found:\n"; + for (const auto &invalid : invalidFiles) { + errorMsg += std::format(" {}.json\n", invalid); + } + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, LocaleDisplayNamesAreConsistent) { + const auto vueLocales = extractGeneralVueLocales(); + const auto localeFiles = getAvailableLocaleFiles(); + std::vector inconsistentDisplayNames; + + // Check that all locales in General.vue have corresponding JSON files + for (const auto &[localeCode, displayName] : vueLocales) { + if (!localeFiles.contains(localeCode)) { + inconsistentDisplayNames.push_back( + std::format("{}: has display name '{}' but no corresponding JSON file exists", localeCode, displayName) + ); + } + } + + // Also check that locale files that exist have entries in General.vue + for (const auto &localeFile : localeFiles) { + if (!vueLocales.contains(localeFile)) { + inconsistentDisplayNames.push_back( + std::format("{}: has JSON file but no display name in General.vue", localeFile) + ); + } + } + + if (!inconsistentDisplayNames.empty()) { + std::string errorMsg = "Locale display name inconsistencies found:\n"; + for (const auto &inconsistent : inconsistentDisplayNames) { + errorMsg += std::format(" {}\n", inconsistent); + } + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, NoOrphanedLocaleReferences) { + const auto configLocales = extractConfigCppLocales(); + const auto vueLocales = extractGeneralVueLocales(); + const auto localeFiles = getAvailableLocaleFiles(); + + std::vector orphanedReferences; + + // Check for locale references that don't have corresponding files + for (const auto &configLocale : configLocales) { + if (!localeFiles.contains(configLocale)) { + orphanedReferences.push_back(std::format("config.cpp references missing file: {}.json", configLocale)); + } + } + + for (const auto &vueLocale : vueLocales | std::views::keys) { + if (!localeFiles.contains(vueLocale)) { + orphanedReferences.push_back(std::format("General.vue references missing file: {}.json", vueLocale)); + } + } + + if (!orphanedReferences.empty()) { + std::string errorMsg = "Orphaned locale references found:\n"; + for (const auto &orphaned : orphanedReferences) { + errorMsg += std::format(" {}\n", orphaned); + } + FAIL() << errorMsg; + } +} + +TEST_F(LocaleConsistencyTest, TestFrameworkDetectsLocaleInconsistencies) { + // Test the framework by simulating a missing locale scenario + const std::string testLocale = "test_framework_validation_locale"; + + auto configLocales = extractConfigCppLocales(); + auto vueLocales = extractGeneralVueLocales(); + const auto localeFiles = getAvailableLocaleFiles(); + + // Add a fake locale to config to simulate a missing file + configLocales.insert(testLocale); + + std::vector missingFiles; + for (const auto &configLocale : configLocales) { + if (!localeFiles.contains(configLocale)) { + missingFiles.push_back(configLocale); + } + } + + // Verify the test framework detects the missing fake locale + bool foundMissingTestLocale = false; + for (const auto &missing : missingFiles) { + if (missing == testLocale) { + foundMissingTestLocale = true; + break; + } + } + + EXPECT_TRUE(foundMissingTestLocale) << "Test framework failed to detect missing locale file"; + EXPECT_GE(missingFiles.size(), 1) << "Test framework should detect at least the fake missing locale"; +}