diff --git a/server/.gitignore b/server/.gitignore index df22b137..22fcfd70 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,9 +1,10 @@ build/ +Testing/ log.* # /.clangd # /CMakeLists.txt # /CMakePresets.json -/tests/CMakeLists.txt -/tests/CMakePresets.json +# /tests/CMakeLists.txt +# /tests/CMakePresets.json /.vs -build-release/ \ No newline at end of file +build-release/ diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index d35ed676..41a1d881 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -5,6 +5,8 @@ set(PROJECT_NAME "MetasequoiaImeServer") cmake_policy(SET CMP0167 NEW) project(${PROJECT_NAME} VERSION 1.0) +include(CTest) + # Boost set(Boost_ROOT "C:/Users/sonnycalcr/scoop/apps/boost/current") set (Boost_USE_STATIC_LIBS ON) @@ -146,3 +148,7 @@ add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD $<$:${WEBVIEW2_VCPKG_ROOT}/bin/WebView2Loader.dll> $ ) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() diff --git a/server/tests/.gitignore b/server/tests/.gitignore index 0e358207..73122182 100644 --- a/server/tests/.gitignore +++ b/server/tests/.gitignore @@ -1,4 +1,5 @@ build/ +Testing/ .vs/ out/ -*.db \ No newline at end of file +*.db diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt new file mode 100644 index 00000000..18747a3a --- /dev/null +++ b/server/tests/CMakeLists.txt @@ -0,0 +1,61 @@ +set(TEST_EXECUTABLE_NAME "MetasequoiaImeServerTests") + +set(TEST_SOURCE_FILES + "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_shuangpin_scheme.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_quanpin_scheme.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_shuangpin_query.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_engine_shuangpin_session.cpp" + "${CMAKE_SOURCE_DIR}/src/config/ime_config.cpp" + "${CMAKE_SOURCE_DIR}/src/session/engine_input_session.cpp" + "${CMAKE_SOURCE_DIR}/src/utils/common_utils.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/core/ime_session.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/common/string_utils.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/providers/provider_registry.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/providers/pinyin_candidate_provider.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/quanpin/engine.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/quanpin/quanpin_dictionary.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/schemes/shuangpin_scheme.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/schemes/quanpin_scheme.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/quanpin/quanpin_query.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/shuangpin/engine.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/shuangpin/shuangpin_dictionary.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/shuangpin/shuangpin_query.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/shuangpin/shuangpin_utils.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/dictbuilder.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/dictlist.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/dicttrie.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/lpicache.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/matrixsearch.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/mystdlib.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/ngram.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/pinyinime.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/searchutility.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/spellingtable.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/spellingtrie.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/splparser.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/sync.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/userdict.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/utf16char.cpp" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/googlepinyinime-rev/src/share/utf16reader.cpp" +) + +add_executable(${TEST_EXECUTABLE_NAME} ${TEST_SOURCE_FILES}) + +target_include_directories(${TEST_EXECUTABLE_NAME} + PRIVATE + "${CMAKE_SOURCE_DIR}" + "${CMAKE_SOURCE_DIR}/src" + "${CMAKE_SOURCE_DIR}/MetasequoiaImeEngine/utfcpp/source" +) + +target_link_libraries(${TEST_EXECUTABLE_NAME} + PRIVATE + fmt::fmt + spdlog::spdlog + Boost::locale + Boost::json + unofficial::sqlite3::sqlite3 +) + +add_test(NAME ${TEST_EXECUTABLE_NAME} COMMAND ${TEST_EXECUTABLE_NAME}) diff --git a/server/tests/README.md b/server/tests/README.md index cbd7021e..493e02a0 100644 --- a/server/tests/README.md +++ b/server/tests/README.md @@ -1,3 +1,91 @@ -# About +# Tests -Test. \ No newline at end of file +This project now includes a lightweight regression test target wired into the main CMake build. + +## What It Covers + +Current tests focus on high-value IME behaviors that are easy to accidentally break during refactors: + +- `shuangpin` scheme request construction +- `quanpin` scheme request construction +- case-preserving shuangpin segmentation +- full-helpcode detection +- `engine + shuangpin` continuation behavior +- cloud suggestion trigger timing for shuangpin + +The goal is not to fully simulate the whole IME UI pipeline yet. The goal is to protect the core input rules and +session behavior. + +## Build The Tests + +From the repository root: + +```powershell +cmake --build build --config Debug --target MetasequoiaImeServerTests +``` + +## Run The Tests + +Recommended: + +```powershell +ctest --test-dir build -C Debug --output-on-failure +``` + +You can also run from inside the build directory: + +```powershell +cd build +ctest -C Debug --output-on-failure +``` + +## Common Pitfall + +If you run `ctest` from the source root directly, you may see: + +```text +No tests were found!!! +``` + +That usually means `ctest` is looking at the source directory instead of the build directory. + +Use one of these instead: + +```powershell +ctest --test-dir build -C Debug --output-on-failure +``` + +or + +```powershell +cd build +ctest -C Debug --output-on-failure +``` + +## Test Layout + +- `tests/src/main.cpp` + - tiny custom test runner +- `tests/includes/test_framework.h` + - lightweight assertion helpers +- `tests/src/test_shuangpin_scheme.cpp` + - shuangpin scheme unit tests +- `tests/src/test_quanpin_scheme.cpp` + - quanpin scheme unit tests +- `tests/src/test_shuangpin_query.cpp` + - query/helper function tests +- `tests/src/test_engine_shuangpin_session.cpp` + - session-level behavior tests + +## Recommended Practice + +When you change input behavior, add a regression test if the change affects: + +- input segmentation +- space/enter/backspace behavior +- helpcode handling +- continuation after candidate selection +- create-word flow +- cloud suggestion timing + +The best tests in this project are the ones that capture a concrete input example that previously broke. diff --git a/server/tests/includes/test_framework.h b/server/tests/includes/test_framework.h new file mode 100644 index 00000000..0c659ebb --- /dev/null +++ b/server/tests/includes/test_framework.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +namespace test +{ +struct TestCase +{ + std::string name; + std::function fn; +}; + +std::vector ®istry(); + +struct Registrar +{ + Registrar(const char *name, std::function fn); +}; +} + +#define TEST_CASE(name) \ + static void name(); \ + static test::Registrar name##_registrar(#name, name); \ + static void name() + +#define REQUIRE(expr) \ + do \ + { \ + if (!(expr)) \ + { \ + throw std::runtime_error("Requirement failed: " #expr); \ + } \ + } while (false) + +#define REQUIRE_EQ(lhs, rhs) \ + do \ + { \ + const auto &_lhs = (lhs); \ + const auto &_rhs = (rhs); \ + if (!(_lhs == _rhs)) \ + { \ + throw std::runtime_error("Requirement failed: " #lhs " == " #rhs); \ + } \ + } while (false) diff --git a/server/tests/src/main.cpp b/server/tests/src/main.cpp index 828b372a..a0b87b8f 100644 --- a/server/tests/src/main.cpp +++ b/server/tests/src/main.cpp @@ -1,67 +1,49 @@ +#include #include -#include -#include +#include "tests/includes/test_framework.h" -int main(int argc, char *argv[]) +namespace test { - std::cout << "This is a code template project for Windows cpp!" << '\n'; - fmt::print("Hello World from fmt!!\n"); +std::vector ®istry() +{ + static std::vector tests; + return tests; +} - sqlite3 *db = nullptr; - char *errMsg = nullptr; +Registrar::Registrar(const char *name, std::function fn) +{ + registry().push_back({name, std::move(fn)}); +} +} // namespace test - // Create or open database - if (sqlite3_open("test.db", &db) != SQLITE_OK) +int main() +{ + int failures = 0; + for (const auto &test_case : test::registry()) { - std::cerr << "Cannot open database: " << sqlite3_errmsg(db) << std::endl; - return 1; - } - - // Create table - const char *createTableSQL = "CREATE TABLE IF NOT EXISTS users (" - "id INTEGER PRIMARY KEY AUTOINCREMENT," - "name TEXT NOT NULL," - "age INTEGER);"; - - if (sqlite3_exec(db, createTableSQL, nullptr, nullptr, &errMsg) != SQLITE_OK) - { - std::cerr << "Failed to create table: " << errMsg << std::endl; - sqlite3_free(errMsg); - sqlite3_close(db); - return 1; - } - - // Insert data - const char *insertSQL = "INSERT INTO users (name, age) VALUES ('Alice', 25);"; - if (sqlite3_exec(db, insertSQL, nullptr, nullptr, &errMsg) != SQLITE_OK) - { - std::cerr << "Failed to insert data: " << errMsg << std::endl; - sqlite3_free(errMsg); - sqlite3_close(db); - return 1; - } - - // Query data - const char *querySQL = "SELECT id, name, age FROM users;"; - auto callback = [](void *, int argc, char **argv, char **colName) -> int { - for (int i = 0; i < argc; ++i) + try { - std::cout << colName[i] << ": " << (argv[i] ? argv[i] : "NULL") << " | "; + test_case.fn(); + fmt::print("[PASS] {}\n", test_case.name); } - std::cout << "\n"; - return 0; - }; + catch (const std::exception &ex) + { + ++failures; + fmt::print(stderr, "[FAIL] {}: {}\n", test_case.name, ex.what()); + } + catch (...) + { + ++failures; + fmt::print(stderr, "[FAIL] {}: unknown exception\n", test_case.name); + } + } - if (sqlite3_exec(db, querySQL, callback, nullptr, &errMsg) != SQLITE_OK) + if (failures > 0) { - std::cerr << "Failed to query data: " << errMsg << std::endl; - sqlite3_free(errMsg); - sqlite3_close(db); + fmt::print(stderr, "{} test(s) failed\n", failures); return 1; } - // Close database - sqlite3_close(db); - std::cout << "All done.\n"; + fmt::print("{} test(s) passed\n", test::registry().size()); return 0; } diff --git a/server/tests/src/test_engine_shuangpin_session.cpp b/server/tests/src/test_engine_shuangpin_session.cpp new file mode 100644 index 00000000..bcf40563 --- /dev/null +++ b/server/tests/src/test_engine_shuangpin_session.cpp @@ -0,0 +1,69 @@ +#include "tests/includes/test_framework.h" +#include "src/session/engine_input_session.h" + +namespace +{ +void InputLetters(EngineInputSession &session, const std::string &keys) +{ + for (const char ch : keys) + { + const bool is_upper = ch >= 'A' && ch <= 'Z'; + const char upper = is_upper ? ch : static_cast(ch - ('a' - 'A')); + session.handle_key(static_cast(upper), 0, static_cast(ch)); + } +} +} + +TEST_CASE(EngineShuangpinSessionContinuesCompositionWithoutHelpcode) +{ + EngineInputSession session(SchemeType::Shuangpin); + InputLetters(session, "xitele"); + + const auto transition = session.advance_composition_after_selection("xi", "西"); + REQUIRE(transition.continues_composition); + REQUIRE_EQ(session.get_pinyin_sequence(), std::string("tele")); +} + +TEST_CASE(EngineShuangpinSessionContinuesCompositionWithSingleHelpcode) +{ + EngineInputSession session(SchemeType::Shuangpin); + InputLetters(session, "xitelea"); + + const auto transition = session.advance_composition_after_selection("xi", "西"); + REQUIRE(transition.continues_composition); + REQUIRE_EQ(session.get_pinyin_sequence(), std::string("tele")); +} + +TEST_CASE(EngineShuangpinSessionContinuesCompositionWithDoubleHelpcode) +{ + EngineInputSession session(SchemeType::Shuangpin); + InputLetters(session, "xiteleaA"); + + const auto transition = session.advance_composition_after_selection("xi", "西"); + REQUIRE(transition.continues_composition); + REQUIRE_EQ(session.get_pinyin_sequence(), std::string("tele")); +} + +TEST_CASE(EngineShuangpinSessionCloudQueryMatchesLegacyTiming) +{ + EngineInputSession session(SchemeType::Shuangpin); + + InputLetters(session, "xi"); + auto state = session.get_cloud_query_state(); + REQUIRE(state.should_query); + + session.reset_state(); + InputLetters(session, "xia"); + state = session.get_cloud_query_state(); + REQUIRE(!state.should_query); + + session.reset_state(); + InputLetters(session, "xiA"); + state = session.get_cloud_query_state(); + REQUIRE(!state.should_query); + + session.reset_state(); + InputLetters(session, "xI"); + state = session.get_cloud_query_state(); + REQUIRE(!state.should_query); +} diff --git a/server/tests/src/test_quanpin_scheme.cpp b/server/tests/src/test_quanpin_scheme.cpp new file mode 100644 index 00000000..cc1c0f52 --- /dev/null +++ b/server/tests/src/test_quanpin_scheme.cpp @@ -0,0 +1,36 @@ +#include "tests/includes/test_framework.h" +#include "MetasequoiaImeEngine/schemes/quanpin_scheme.h" + +namespace +{ +void InputKey(QuanpinScheme &scheme, UINT vk, WCHAR wch, UINT modifiers_down = 0) +{ + scheme.handle_key(vk, modifiers_down, wch); +} +} + +TEST_CASE(QuanpinSchemeSpaceDoesNotResetComposition) +{ + QuanpinScheme scheme; + InputKey(scheme, 'N', L'n'); + InputKey(scheme, 'I', L'i'); + InputKey(scheme, VK_SPACE, L' '); + + const QueryRequest request = scheme.build_request(); + REQUIRE(request.valid); + REQUIRE_EQ(request.raw_input, std::string("ni")); + REQUIRE_EQ(request.normalized_input, std::string("ni")); +} + +TEST_CASE(QuanpinSchemeApostropheIsPreservedInRawInput) +{ + QuanpinScheme scheme; + InputKey(scheme, 'X', L'x'); + InputKey(scheme, VK_OEM_7, L'\''); + InputKey(scheme, 'I', L'i'); + + const QueryRequest request = scheme.build_request(); + REQUIRE(request.valid); + REQUIRE_EQ(request.raw_input, std::string("x'i")); + REQUIRE_EQ(request.normalized_input, std::string("xi")); +} diff --git a/server/tests/src/test_shuangpin.cpp b/server/tests/src/test_shuangpin.cpp deleted file mode 100644 index 9ed094e8..00000000 --- a/server/tests/src/test_shuangpin.cpp +++ /dev/null @@ -1,22 +0,0 @@ -#include -#include -#include "fmt/base.h" -#include "MetasequoiaImeEngine/shuangpin/shuangpin_dictionary.h" -#include - -int main(int argc, char *argv[]) -{ - DictionaryUlPb dict; - std::vector result = dict.generate("ceui", "ce'ui"); - std::chrono::high_resolution_clock::time_point start = std::chrono::high_resolution_clock::now(); - result = dict.generate("name", "na'me"); - std::chrono::high_resolution_clock::time_point end = std::chrono::high_resolution_clock::now(); - auto duration = std::chrono::duration_cast(end - start); - fmt::println("Time: {} us", duration.count()); - fmt::println("Word count: {}", result.size()); - for (const auto &[pinyin, word, weight] : result) - { - fmt::println("Word: {}", word); - } - return 0; -} diff --git a/server/tests/src/test_shuangpin_query.cpp b/server/tests/src/test_shuangpin_query.cpp new file mode 100644 index 00000000..6e35b5de --- /dev/null +++ b/server/tests/src/test_shuangpin_query.cpp @@ -0,0 +1,17 @@ +#include "tests/includes/test_framework.h" +#include "MetasequoiaImeEngine/shuangpin/shuangpin_query.h" +#include "MetasequoiaImeEngine/shuangpin/shuangpin_utils.h" + +TEST_CASE(ApplySegmentationCasesPreservesUppercaseMarkers) +{ + const std::string segmented = "xi'te'le'aa"; + const std::string cased = "xiteleaA"; + REQUIRE_EQ(shuangpin::apply_segmentation_cases(segmented, cased), std::string("xi'te'le'aA")); +} + +TEST_CASE(IsFullHelpModeRecognizesDoubleHelpcodePattern) +{ + REQUIRE(ShuangpinUtil::IsFullHelpMode("xiteleaA")); + REQUIRE(!ShuangpinUtil::IsFullHelpMode("xitelea")); + REQUIRE(!ShuangpinUtil::IsFullHelpMode("xi")); +} diff --git a/server/tests/src/test_shuangpin_scheme.cpp b/server/tests/src/test_shuangpin_scheme.cpp new file mode 100644 index 00000000..37a0eef6 --- /dev/null +++ b/server/tests/src/test_shuangpin_scheme.cpp @@ -0,0 +1,61 @@ +#include "tests/includes/test_framework.h" +#include "MetasequoiaImeEngine/schemes/shuangpin_scheme.h" + +namespace +{ +void InputKey(ShuangpinScheme &scheme, UINT vk, WCHAR wch, UINT modifiers_down = 0) +{ + scheme.handle_key(vk, modifiers_down, wch); +} +} + +TEST_CASE(ShuangpinSchemeBuildRequestPreservesCaseAndNormalizesQuery) +{ + ShuangpinScheme scheme; + InputKey(scheme, 'X', L'X', 1); + InputKey(scheme, 'I', L'i'); + + const QueryRequest request = scheme.build_request(); + REQUIRE(request.valid); + REQUIRE_EQ(request.raw_input, std::string("xi")); + REQUIRE_EQ(request.raw_input_with_cases, std::string("Xi")); + REQUIRE_EQ(request.raw_segmentation, std::string("Xi")); + REQUIRE_EQ(request.normalized_input, std::string("xi")); +} + +TEST_CASE(ShuangpinSchemeSpaceDoesNotResetComposition) +{ + ShuangpinScheme scheme; + InputKey(scheme, 'X', L'x'); + InputKey(scheme, 'I', L'i'); + InputKey(scheme, VK_SPACE, L' '); + + const QueryRequest request = scheme.build_request(); + REQUIRE(request.valid); + REQUIRE_EQ(request.raw_input, std::string("xi")); +} + +TEST_CASE(ShuangpinSchemeEnterResetsComposition) +{ + ShuangpinScheme scheme; + InputKey(scheme, 'X', L'x'); + InputKey(scheme, 'I', L'i'); + InputKey(scheme, VK_RETURN, 0); + + const QueryRequest request = scheme.build_request(); + REQUIRE(!request.valid); + REQUIRE_EQ(request.raw_input, std::string("")); +} + +TEST_CASE(ShuangpinSchemeBackspaceRemovesLastInput) +{ + ShuangpinScheme scheme; + InputKey(scheme, 'X', L'x'); + InputKey(scheme, 'I', L'i'); + InputKey(scheme, VK_BACK, 0); + + const QueryRequest request = scheme.build_request(); + REQUIRE(request.valid); + REQUIRE_EQ(request.raw_input, std::string("x")); + REQUIRE_EQ(request.raw_input_with_cases, std::string("x")); +}