diff --git a/.clangd b/.clangd index bf76aa8e2d..abb6307119 100644 --- a/.clangd +++ b/.clangd @@ -1,5 +1,14 @@ +If: + PathMatch: unit_test/.*\.(c|cc|cpp|cxx|h|hh|hpp)$ +CompileFlags: + CompilationDatabase: build/unit_test + Add: [-Wunused-variables] +--- +If: + PathExclude: unit_test/.*\.(c|cc|cpp|cxx|h|hh|hpp)$ CompileFlags: CompilationDatabase: build Add: [-Wunused-variables] +--- Diagnostics: - UnusedIncludes: None + UnusedIncludes: None diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000000..a9484766f4 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,122 @@ +name: Unit Tests + +on: + # push: + # branches: + # - "dev-v2" + # paths: + # - ".github/workflows/unit-tests.yml" + # - "unit_test/**" + # - "src/**" + # pull_request: + # paths: + # - ".github/workflows/unit-tests.yml" + # - "unit_test/**" + # - "src/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + select-tests: + name: Select Unit Test Suites + runs-on: ubuntu-latest + outputs: + has_tests: ${{ steps.select.outputs.has_tests }} + matrix: ${{ steps.select.outputs.matrix }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + show-progress: false + + - name: Select affected test suites + id: select + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + python3 - <<'PY' + import json + import os + import subprocess + + def matches(path: str, rule: str) -> bool: + if rule.endswith('/**'): + return path.startswith(rule[:-3]) + return path == rule + + with open('unit_test/test-suites.json', encoding='utf-8') as f: + mapping = json.load(f) + + event_name = os.environ['EVENT_NAME'] + changed_files = [] + run_all = event_name == 'workflow_dispatch' + + if not run_all: + if event_name == 'pull_request': + base_sha = os.environ['PR_BASE_SHA'] + head_sha = os.environ['PR_HEAD_SHA'] + else: + base_sha = os.environ['BEFORE_SHA'] + head_sha = os.environ['HEAD_SHA'] + + if not base_sha or set(base_sha) == {'0'}: + run_all = True + else: + diff = subprocess.check_output( + ['git', 'diff', '--name-only', base_sha, head_sha], + text=True, + ) + changed_files = [line for line in diff.splitlines() if line] + + print('Changed files:') + for file_path in changed_files: + print(f' - {file_path}') + + if run_all or any(any(matches(path, rule) for rule in mapping['runAllOnChanges']) for path in changed_files): + selected = mapping['suites'] + else: + selected = [] + for suite in mapping['suites']: + if any(any(matches(path, rule) for rule in suite['paths']) for path in changed_files): + selected.append(suite) + + matrix = {'suite': selected} + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as f: + f.write(f"has_tests={'true' if selected else 'false'}\n") + f.write(f"matrix={json.dumps(matrix, separators=(',', ':'))}\n") + PY + + unit-tests: + name: ${{ matrix.suite.name }} Unit Tests + needs: select-tests + if: ${{ needs.select-tests.outputs.has_tests == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.select-tests.outputs.matrix) }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + show-progress: false + + - name: Configure unit tests + run: | + cmake -S unit_test -B build/unit_test -DCMAKE_BUILD_TYPE=Release + + - name: Build selected unit test target + run: | + cmake --build build/unit_test --parallel --target ${{ matrix.suite.buildTarget }} + + - name: Run selected unit tests + run: | + ctest --test-dir build/unit_test --output-on-failure -R '${{ matrix.suite.ctestRegex }}' \ No newline at end of file diff --git a/unit_test/CMakeLists.txt b/unit_test/CMakeLists.txt new file mode 100644 index 0000000000..e111eae624 --- /dev/null +++ b/unit_test/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.24) + +project(MAAUnitTests LANGUAGES CXX) + +include(CTest) +include(FetchContent) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CATCH_INSTALL_DOCS OFF CACHE BOOL "" FORCE) +set(CATCH_INSTALL_EXTRAS ON CACHE BOOL "" FORCE) +set(CATCH_DEVELOPMENT_BUILD OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.8.1 + GIT_SHALLOW TRUE +) + +FetchContent_MakeAvailable(Catch2) + +include(Catch) + +add_executable(maa-algorithm-test + MaaCore/AlgorithmTest.cpp +) + +target_compile_features(maa-algorithm-test PRIVATE cxx_std_20) +target_link_libraries(maa-algorithm-test PRIVATE Catch2::Catch2WithMain) +target_include_directories(maa-algorithm-test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaaCore) + +catch_discover_tests(maa-algorithm-test TEST_PREFIX "algorithm::") \ No newline at end of file diff --git a/unit_test/MaaCore/AlgorithmTest.cpp b/unit_test/MaaCore/AlgorithmTest.cpp new file mode 100644 index 0000000000..4523386b78 --- /dev/null +++ b/unit_test/MaaCore/AlgorithmTest.cpp @@ -0,0 +1,204 @@ +#include + +#include +#include +#include +#include + +#include "Utils/Algorithm.hpp" + +namespace +{ +using GroupList = std::unordered_map>; +using CharSet = std::unordered_set; + +void require_valid_allocation(const GroupList& group_list, const CharSet& char_set, + const asst::algorithm::CharAllocationResult& result) +{ + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::Success); + REQUIRE(result.has_value()); + REQUIRE(result.allocation.size() == group_list.size()); + + for (const auto& [group_name, allocated_char] : result.allocation) { + INFO("group_name=" << group_name); + REQUIRE(group_list.contains(group_name)); + REQUIRE(char_set.contains(allocated_char)); + } + + std::unordered_set used_chars; + for (const auto& [group_name, candidates] : group_list) { + INFO("group_name=" << group_name); + + const auto allocation_it = result.allocation.find(group_name); + REQUIRE(allocation_it != result.allocation.end()); + + const auto& assigned_char = allocation_it->second; + REQUIRE(char_set.contains(assigned_char)); + + bool candidate_found = false; + for (const auto& candidate : candidates) { + if (candidate == assigned_char) { + candidate_found = true; + break; + } + } + REQUIRE(candidate_found); + REQUIRE(used_chars.emplace(assigned_char).second); + } +} +} // namespace + +TEST_CASE("Empty group list returns empty success result") +{ + const GroupList groups; + const CharSet chars { "Amiya" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::Success); + REQUIRE(result.has_value()); + REQUIRE(result.allocation.empty()); +} + +TEST_CASE("Empty char set returns no solution") +{ + const GroupList groups { { "先锋", { "德克萨斯" } } }; + const CharSet chars; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::NoSolution); + REQUIRE_FALSE(result.has_value()); +} + +TEST_CASE("Exact matching returns expected allocation") +{ + const GroupList groups { + { "先锋", { "德克萨斯" } }, + { "术师", { "阿米娅" } }, + }; + const CharSet chars { "德克萨斯", "阿米娅" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::Success); + REQUIRE(result.has_value()); + REQUIRE(result.allocation == std::unordered_map { + { "先锋", "德克萨斯" }, + { "术师", "阿米娅" }, + }); +} + +TEST_CASE("Duplicate candidates do not break matching") +{ + const GroupList groups { + { "先锋", { "德克萨斯", "德克萨斯" } }, + { "术师", { "阿米娅", "阿米娅" } }, + }; + const CharSet chars { "德克萨斯", "阿米娅" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::Success); + REQUIRE(result.has_value()); + REQUIRE(result.allocation == std::unordered_map { + { "先锋", "德克萨斯" }, + { "术师", "阿米娅" }, + }); +} + +TEST_CASE("Unowned candidates are filtered before matching") +{ + const GroupList groups { + { "先锋", { "风笛", "德克萨斯" } }, + { "术师", { "刻俄柏", "阿米娅" } }, + }; + const CharSet chars { "德克萨斯", "阿米娅" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::Success); + REQUIRE(result.has_value()); + REQUIRE(result.allocation == std::unordered_map { + { "先锋", "德克萨斯" }, + { "术师", "阿米娅" }, + }); +} + +TEST_CASE("Conflicting groups return no solution") +{ + const GroupList groups { + { "先锋", { "推进之王" } }, + { "近卫", { "推进之王" } }, + }; + const CharSet chars { "推进之王" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::NoSolution); + REQUIRE_FALSE(result.has_value()); +} + +TEST_CASE("Multiple groups can find a valid allocation") +{ + const GroupList groups { + { "先锋", { "德克萨斯", "桃金娘" } }, + { "术师", { "阿米娅", "伊芙利特" } }, + { "医疗", { "闪灵", "夜莺" } }, + }; + const CharSet chars { "桃金娘", "阿米娅", "夜莺" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + require_valid_allocation(groups, chars, result); +} + +TEST_CASE("Multiple groups allocation ignores extra owned chars") +{ + const GroupList groups { + { "先锋", { "德克萨斯", "桃金娘" } }, + { "术师", { "阿米娅", "伊芙利特" } }, + { "医疗", { "闪灵", "夜莺" } }, + }; + const CharSet chars { + "桃金娘", + "阿米娅", + "夜莺", + "德克萨斯", + "伊芙利特", + "闪灵", + "能天使", + }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + require_valid_allocation(groups, chars, result); +} + +TEST_CASE("Group without any owned candidate returns no solution") +{ + const GroupList groups { + { "先锋", { "德克萨斯" } }, + { "术师", { "阿米娅" } }, + }; + const CharSet chars { "德克萨斯" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::NoSolution); + REQUIRE_FALSE(result.has_value()); +} + +TEST_CASE("Group with empty candidate list returns no solution") +{ + const GroupList groups { + { "先锋", {} }, + { "术师", { "阿米娅" } }, + }; + const CharSet chars { "阿米娅" }; + + const auto result = asst::algorithm::get_char_allocation_for_each_group(groups, chars); + + REQUIRE(result.status == asst::algorithm::CharAllocationStatus::NoSolution); + REQUIRE_FALSE(result.has_value()); +} \ No newline at end of file diff --git a/unit_test/test-suites.json b/unit_test/test-suites.json new file mode 100644 index 0000000000..622578b776 --- /dev/null +++ b/unit_test/test-suites.json @@ -0,0 +1,19 @@ +{ + "runAllOnChanges": [ + "unit_test/CMakeLists.txt", + "unit_test/test-suites.json", + ".github/workflows/unit-tests.yml" + ], + "suites": [ + { + "id": "maa-algorithm-test", + "name": "Algorithm", + "buildTarget": "maa-algorithm-test", + "ctestRegex": "^algorithm::", + "paths": [ + "src/MaaCore/Utils/Algorithm.hpp", + "unit_test/MaaCore/AlgorithmTest.cpp" + ] + } + ] +} \ No newline at end of file