Files
msime-windows/engine/tests/src/test_candidate_removal.cpp
T
Jia Sui 4fde941291 fix(engine): 测试的 main 再补一层 catch (...)
只接 std::exception 的话,第三方库或跨 FFI 边界抛出来的东西照样会逃出 main 走 abort,
和上一个 commit 修掉的是同一类故障:ctest 看不到原因,栈也不展开。引擎内现有的抛出确实
都是 std::exception 派生,但这一层缺的是「无论什么异常都到不了 terminate」的保证,补上它
才让 main 的边界真正闭合。

只加 catch 分支,输出通道沿用各文件原来那一个(fprintf / cerr / fmt::println),
不改任何断言。
2026-09-30 21:34:12 +08:00

248 lines
12 KiB
C++

#include <metasequoia/session.h>
#include "../../core/data_path.h"
#include "../../contracts/assets/assets.h"
#include "../../english/english_dictionary.h"
#include "../../user_dictionary/user_dictionary_journal.h"
#include "test_directory_cleanup.h"
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <fstream>
#include <stdexcept>
#include <cstdio>
namespace
{
using namespace metasequoia;
void require(bool value, const char *message)
{
if (!value)
throw std::runtime_error(message);
}
void execute(const std::filesystem::path &path, const std::string &sql)
{
sqlite3 *db = nullptr;
require(sqlite3_open(path_to_utf8(path).c_str(), &db) == SQLITE_OK, "Fixture open failed");
const int result = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, nullptr);
sqlite3_close(db);
require(result == SQLITE_OK, "Fixture SQL failed");
}
int count(const std::filesystem::path &path, const std::string &sql)
{
sqlite3 *db = nullptr;
require(sqlite3_open_v2(path_to_utf8(path).c_str(), &db, SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK,
"Query open failed");
sqlite3_stmt *stmt = nullptr;
require(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK, "Query prepare failed");
require(sqlite3_step(stmt) == SQLITE_ROW, "Query failed");
const int result = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
sqlite3_close(db);
return result;
}
std::string bytes(const std::filesystem::path &path)
{
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
void type(Session &session, const std::string &input)
{
for (char c : input)
session.character(c);
}
bool has(const Session &session, const std::string &word)
{
const auto view = session.snapshot();
return std::any_of(view.candidates.begin(), view.candidates.end(),
[&](const auto &item) { return item.word == word; });
}
std::size_t index_of(const Session &session, const std::string &word)
{
const auto view = session.snapshot();
const auto item = std::find_if(view.candidates.begin(), view.candidates.end(),
[&](const auto &candidate) { return candidate.word == word; });
require(item != view.candidates.end(), "Missing test candidate");
return static_cast<std::size_t>(item - view.candidates.begin());
}
} // namespace
int run_test()
{
using namespace metasequoia;
const auto root = std::filesystem::temp_directory_path() /
("msime-remove-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
test::ScopedDataDirectoryCleanup cleanup(root);
const auto resources = root / "resources";
std::filesystem::create_directories(resources / "helpcodes");
std::ofstream(resources / assets::helpcode_lantian) << "你=aa\n拟=cc\n";
execute(resources / assets::main_dictionary,
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_n VALUES('ni','n','你',10000);"
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',10000),('ni''hao','nh','拟好',9000);"
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);"
"INSERT INTO wubi86 VALUES('wq','你好',10000),('wq','拟好',9000);");
require(EnglishDictionary::ensure_schema(path_to_utf8(resources / assets::english_dictionary)),
"English schema failed");
execute(resources / assets::english_dictionary,
"INSERT INTO english_words(word,display,weight) VALUES('hello','hello',100),('help','help',50);");
const auto main_before = bytes(resources / assets::main_dictionary);
const auto english_before = bytes(resources / assets::english_dictionary);
// A later deletion must win over an earlier pin when the journal is replayed.
for (const bool pin_first : {false, true})
for (const auto scheme : {SchemeType::Quanpin, SchemeType::Shuangpin, SchemeType::Wubi})
{
const auto suffix = std::to_string(static_cast<int>(scheme)) + (pin_first ? "-pinned" : "");
const auto user = root / ("user-" + suffix);
const auto cache = root / ("cache-" + suffix);
SessionOptions options;
options.paths = prepare_runtime_paths(resources, user, cache, "v1");
options.scheme = scheme;
options.learning = false;
const std::string input =
scheme == SchemeType::Quanpin ? "nihao" : (scheme == SchemeType::Shuangpin ? "nihc" : "wq");
{
Session session(options);
require(!session.remove(0).handled, "Empty removal consumed input");
type(session, input);
const auto before = session.snapshot();
require(!session.remove(before.candidates.size()).handled, "Out of bounds removal accepted");
if (pin_first)
{
const auto pinned = session.pin(index_of(session, "拟好"));
require(pinned.handled && !pinned.commit && !pinned.diagnostic, "Pin before removal failed");
}
const auto result = session.remove(index_of(session, "拟好"));
require(result.handled && !result.commit && !result.diagnostic, "Removal failed or committed text");
require(session.snapshot().preedit == before.preedit && !has(session, "拟好") && has(session, "你好"),
"Removal did not refresh only the selected candidate");
}
options.paths = prepare_runtime_paths(resources, user, cache, "v2");
{
Session replayed(options);
type(replayed, input);
require(!has(replayed, "拟好") && has(replayed, "你好"),
"Removed phrase reappeared after upgrade replay");
}
options.paths = prepare_runtime_paths(resources, user, cache, "v1");
Session rolled_back(options);
type(rolled_back, input);
require(!has(rolled_back, "拟好"), "Removed phrase reappeared after rollback");
}
for (const bool pin_first : {false, true})
for (const int mode : {0, 1, 2})
{
SessionOptions options;
const auto suffix = std::to_string(mode) + (pin_first ? "-pinned" : "");
const auto user = root / ("english-user-" + suffix);
const auto cache = root / ("english-cache-" + suffix);
options.paths = prepare_runtime_paths(resources, user, cache, "v1");
options.learning = false;
options.english.mixed_candidates = mode == 2;
{
Session session(options);
if (mode == 0)
session.set_dedicated_english(true);
else if (mode == 1)
session.character('Y', true);
type(session, "he");
const auto before = session.snapshot().preedit;
if (pin_first)
{
const auto pinned = session.pin(index_of(session, "help"));
require(pinned.handled && !pinned.commit && !pinned.diagnostic,
"English pin before removal failed");
}
const auto result = session.remove(index_of(session, "help"));
require(result.handled && !result.commit && !result.diagnostic &&
session.snapshot().preedit == before && !has(session, "help") && has(session, "hello"),
"English removal lost input or failed to refresh candidates");
}
options.paths = prepare_runtime_paths(resources, user, cache, "v2");
Session replayed(options);
replayed.set_dedicated_english(true);
type(replayed, "he");
require(!has(replayed, "help") && has(replayed, "hello"), "English deletion did not survive replay");
}
SessionOptions failure_options;
failure_options.paths = prepare_runtime_paths(resources, root / "failure-user", root / "failure-cache", "v1");
failure_options.learning = false;
const auto journal = failure_options.paths.user(assets::user_journal);
require(user_dictionary::ensure_user_database(path_to_utf8(journal)), "Journal fixture failed");
execute(journal, "CREATE TRIGGER reject_removal BEFORE INSERT ON user_dictionary_operations "
"BEGIN SELECT RAISE(ABORT,'fixture rejection'); END;");
{
Session session(failure_options);
type(session, "nihao");
const auto before = session.snapshot().preedit;
const auto result = session.remove(index_of(session, "拟好"));
require(result.handled && result.diagnostic && !result.commit && has(session, "拟好") &&
session.snapshot().preedit == before,
"Failed journal write lost composition");
require(count(failure_options.paths.dictionary(assets::main_dictionary),
"SELECT count(*) FROM tbl_2_n WHERE value='拟好'") == 1,
"Journal failure did not roll back deletion");
session.command(Command::Cancel);
type(session, "ni");
require(!session.remove(index_of(session, "你")).handled, "Single character was not protected");
session.command(Command::Cancel);
session.set_dedicated_english(true);
type(session, "he");
const auto english_result = session.remove(index_of(session, "help"));
require(english_result.diagnostic && has(session, "help"), "English journal failure was hidden");
require(count(failure_options.paths.dictionary(assets::english_dictionary),
"SELECT count(*) FROM english_words WHERE word='help'") == 1,
"English deletion was not rolled back");
}
// The legacy English caller uses the same rollback guarantee.
require(!user_dictionary::delete_english_candidate(
path_to_utf8(failure_options.paths.dictionary(assets::english_dictionary)), path_to_utf8(journal),
"help", "help"),
"Legacy English deletion concealed journal failure");
require(count(failure_options.paths.dictionary(assets::english_dictionary),
"SELECT count(*) FROM english_words WHERE word='help'") == 1,
"Legacy English deletion lost an unjournaled row");
execute(journal, "DROP TRIGGER reject_removal");
// Canonical syllable boundaries are data, even when joining them spells another valid key.
execute(failure_options.paths.dictionary(assets::main_dictionary),
"CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_x VALUES('xian','x','西安',100);"
"CREATE TABLE tbl_2_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_x VALUES('xi''an','xa','西安',100);");
require(user_dictionary::delete_dictionary_candidate(
path_to_utf8(failure_options.paths.dictionary(assets::main_dictionary)), path_to_utf8(journal),
user_dictionary::DictionaryKind::Pinyin, "xi'an", "西安"),
"Canonical deletion failed");
require(count(failure_options.paths.dictionary(assets::main_dictionary),
"SELECT count(*) FROM tbl_1_x WHERE key='xian'") == 1 &&
count(failure_options.paths.dictionary(assets::main_dictionary), "SELECT count(*) FROM tbl_2_x") == 0,
"Deletion resegmented the canonical key");
require(count(journal,
"SELECT count(*) FROM user_dictionary_operations WHERE operation='delete' AND key='xi''an'") == 1,
"Deletion journal lost canonical boundaries");
require(count(journal, "SELECT count(*) FROM user_dictionary_operations") == 1,
"Failed removal left extra tombstones");
require(main_before == bytes(resources / assets::main_dictionary) &&
english_before == bytes(resources / assets::english_dictionary),
"Removal modified immutable resources");
return 0;
}
int main()
{
try
{
return run_test();
}
catch (const std::exception &error)
{
std::fprintf(stderr, "%s\n", error.what());
return 1;
}
catch (...)
{
std::fprintf(stderr, "An exception escaped the test body.\n");
return 1;
}
}