mirror of
https://github.com/MaaAssistantArknights/MaaAssistantArknights.git
synced 2026-07-01 01:10:34 +08:00
feat: 添加单元测试框架和验证角色分配算法的测试用例 (#16245)
This commit is contained in:
9
.clangd
9
.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
|
||||
|
||||
122
.github/workflows/unit-tests.yml
vendored
Normal file
122
.github/workflows/unit-tests.yml
vendored
Normal file
@@ -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 }}'
|
||||
37
unit_test/CMakeLists.txt
Normal file
37
unit_test/CMakeLists.txt
Normal file
@@ -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::")
|
||||
204
unit_test/MaaCore/AlgorithmTest.cpp
Normal file
204
unit_test/MaaCore/AlgorithmTest.cpp
Normal file
@@ -0,0 +1,204 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "Utils/Algorithm.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
using GroupList = std::unordered_map<std::string, std::vector<std::string>>;
|
||||
using CharSet = std::unordered_set<std::string>;
|
||||
|
||||
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<std::string> 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<std::string, std::string> {
|
||||
{ "先锋", "德克萨斯" },
|
||||
{ "术师", "阿米娅" },
|
||||
});
|
||||
}
|
||||
|
||||
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<std::string, std::string> {
|
||||
{ "先锋", "德克萨斯" },
|
||||
{ "术师", "阿米娅" },
|
||||
});
|
||||
}
|
||||
|
||||
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<std::string, std::string> {
|
||||
{ "先锋", "德克萨斯" },
|
||||
{ "术师", "阿米娅" },
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
19
unit_test/test-suites.json
Normal file
19
unit_test/test-suites.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user