双拼辅助码新增「直接辅助码」开关(默认开,行为不变)。关掉后末尾辅助码改由引导键触发: 反引号、分号、大写字母可多选,触发键后接一到两个辅码(如 ulpb;xy、ulpbXy),一码重排、 两码筛选,只在输入末尾生效。触发键在 TSF 缓冲与 Server raw 里统一记成 |,与句中辅助码的 反引号段区分。 两组触发键互斥:一边勾上某个键会取消另一边的同一个键;手改配置两边都勾时归句中辅助码。 新增 Worker 消息 EndHelpcodeTriggersChanged(37),旧 DLL 忽略。 设置页把句中辅助码单独成区(仅双拼),其中间接(引导键)与直接(万象式)两种方式互斥。 新配置项同时写进出厂模板与开发配置。
3763 lines
230 KiB
C++
3763 lines
230 KiB
C++
#include "../../core/input_session.h"
|
||
#include "../../common/helpcode_utils.h"
|
||
#include "../../core/data_path.h"
|
||
#include "../../user_dictionary/user_dictionary_journal.h"
|
||
#include "test_directory_cleanup.h"
|
||
#include "../../contracts/dictionary/format.h"
|
||
#include "../../quanpin/quanpin_query.h"
|
||
#include "../../quanpin/quanpin_utils.h"
|
||
#include "../../quanpin/quanpin_dictionary.h"
|
||
#include "../../schemes/quanpin_scheme.h"
|
||
#include "../../shuangpin/shuangpin_profile.h"
|
||
|
||
#include <sqlite3.h>
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <chrono>
|
||
#include <cstdio>
|
||
#include <cstdlib>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <stdexcept>
|
||
#include <string>
|
||
|
||
namespace
|
||
{
|
||
class Database
|
||
{
|
||
public:
|
||
explicit Database(const std::filesystem::path &path)
|
||
{
|
||
if (sqlite3_open(metasequoia::path_to_utf8(path).c_str(), &database_) != SQLITE_OK)
|
||
{
|
||
throw std::runtime_error("Failed to create the input-session test dictionary.");
|
||
}
|
||
}
|
||
|
||
~Database()
|
||
{
|
||
sqlite3_close(database_);
|
||
}
|
||
|
||
void execute(const char *sql)
|
||
{
|
||
char *error = nullptr;
|
||
if (sqlite3_exec(database_, sql, nullptr, nullptr, &error) != SQLITE_OK)
|
||
{
|
||
const std::string message = error == nullptr ? "SQLite operation failed." : error;
|
||
sqlite3_free(error);
|
||
throw std::runtime_error(message);
|
||
}
|
||
}
|
||
|
||
std::int64_t query_integer(const char *sql)
|
||
{
|
||
sqlite3_stmt *statement = nullptr;
|
||
if (sqlite3_prepare_v2(database_, sql, -1, &statement, nullptr) != SQLITE_OK ||
|
||
sqlite3_step(statement) != SQLITE_ROW)
|
||
{
|
||
sqlite3_finalize(statement);
|
||
throw std::runtime_error("Failed to query the input-session test dictionary.");
|
||
}
|
||
const std::int64_t value = sqlite3_column_int64(statement, 0);
|
||
sqlite3_finalize(statement);
|
||
return value;
|
||
}
|
||
|
||
private:
|
||
sqlite3 *database_ = nullptr;
|
||
};
|
||
|
||
void type(metasequoia::InputSession &session, const std::string &text)
|
||
{
|
||
for (const char character : text)
|
||
{
|
||
if (!session.handle_character(character).handled)
|
||
{
|
||
throw std::runtime_error("A pinyin character was not handled.");
|
||
}
|
||
}
|
||
}
|
||
|
||
void require(bool condition, const char *message)
|
||
{
|
||
if (!condition)
|
||
{
|
||
throw std::runtime_error(message);
|
||
}
|
||
}
|
||
|
||
void write_file(const std::filesystem::path &path, const std::string &contents)
|
||
{
|
||
std::filesystem::create_directories(path.parent_path());
|
||
std::ofstream stream(path);
|
||
stream << contents;
|
||
if (!stream)
|
||
{
|
||
throw std::runtime_error("Failed to prepare an input-session helpcode fixture.");
|
||
}
|
||
}
|
||
|
||
void set_data_directory(const std::filesystem::path &directory)
|
||
{
|
||
#ifdef _WIN32
|
||
if (_wputenv_s(L"METASEQUOIA_IME_DATA_DIR", directory.c_str()) != 0)
|
||
#else
|
||
if (setenv("METASEQUOIA_IME_DATA_DIR", metasequoia::path_to_utf8(directory).c_str(), 1) != 0)
|
||
#endif
|
||
{
|
||
throw std::runtime_error("Failed to set the data directory override.");
|
||
}
|
||
}
|
||
|
||
void prepare_frequency_fixture(const std::filesystem::path &directory)
|
||
{
|
||
std::filesystem::create_directories(directory);
|
||
Database database(directory / "msime.db");
|
||
database.execute("BEGIN;"
|
||
"CREATE TABLE tbl_1_n(key TEXT, jp TEXT, value TEXT, weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '甲', 100);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '乙', 90);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '丙', 80);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '丁', 70);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '戊', 60);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '己', 50);"
|
||
"COMMIT;");
|
||
}
|
||
|
||
void prepare_shuangpin_frequency_fixture(const std::filesystem::path &directory)
|
||
{
|
||
std::filesystem::create_directories(directory);
|
||
Database database(directory / "msime.db");
|
||
database.execute("BEGIN;"
|
||
"CREATE TABLE tbl_2_n(key TEXT, jp TEXT, value TEXT, weight INTEGER);"
|
||
"INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', '你好', 100);"
|
||
"INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', '拟好', 50);"
|
||
"COMMIT;");
|
||
}
|
||
|
||
void prepare_wubi_frequency_fixture(const std::filesystem::path &directory)
|
||
{
|
||
std::filesystem::create_directories(directory);
|
||
Database database(directory / "msime.db");
|
||
database.execute("BEGIN;"
|
||
"CREATE TABLE wubi86(key TEXT, value TEXT, weight INTEGER);"
|
||
"INSERT INTO wubi86 VALUES('aaaa', '工', 100);"
|
||
"INSERT INTO wubi86 VALUES('aaaa', '或', 50);"
|
||
"COMMIT;");
|
||
}
|
||
|
||
std::size_t candidate_index(const metasequoia::InputSession &session, const std::string &word)
|
||
{
|
||
const auto found = std::find_if(session.candidates().begin(), session.candidates().end(),
|
||
[&](const WordItem &item) { return item.word == word; });
|
||
if (found == session.candidates().end())
|
||
{
|
||
std::string message = "The expected edge-selection candidate was not produced: " + word + "; actual:";
|
||
for (const auto &candidate : session.candidates())
|
||
{
|
||
message += " [" + candidate.word + "]";
|
||
}
|
||
throw std::runtime_error(message);
|
||
}
|
||
return static_cast<std::size_t>(std::distance(session.candidates().begin(), found));
|
||
}
|
||
|
||
bool same_candidate_words(const metasequoia::InputSession &left, const metasequoia::InputSession &right)
|
||
{
|
||
if (left.candidates().size() != right.candidates().size())
|
||
{
|
||
return false;
|
||
}
|
||
return std::equal(left.candidates().begin(), left.candidates().end(), right.candidates().begin(),
|
||
[](const auto &left_item, const auto &right_item) { return left_item.word == right_item.word; });
|
||
}
|
||
|
||
// 同 candidate_index,但未命中返回 candidates().size() 而不是拖出:供「不得出现」断言用。
|
||
std::size_t find_candidate_index(const metasequoia::InputSession &session, const std::string &word)
|
||
{
|
||
const auto found = std::find_if(session.candidates().begin(), session.candidates().end(),
|
||
[&](const WordItem &item) { return item.word == word; });
|
||
return static_cast<std::size_t>(std::distance(session.candidates().begin(), found));
|
||
}
|
||
|
||
// ü 系拼写别名归一与轻标记的会话级回归(AC1–AC6)。自建隔离词库,不依赖主 fixture:
|
||
// 词库正键全部用标准拼写,另放真实音节 nu/lu 供隔离断言。打标与开关无关,掩码取
|
||
// both 仅代表真实前端配置。
|
||
void run_umlaut_alias_session_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "umlaut-alias";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('nve', 'n', '虐', 100);"
|
||
"INSERT INTO tbl_1_n VALUES('nv', 'n', '女', 90);"
|
||
"INSERT INTO tbl_1_n VALUES('nu', 'n', '怒', 80);");
|
||
database.execute("CREATE TABLE tbl_1_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_l VALUES('lve', 'l', '略', 100);"
|
||
"INSERT INTO tbl_1_l VALUES('lv', 'l', '绿', 90);"
|
||
"INSERT INTO tbl_1_l VALUES('lu', 'l', '路', 80);");
|
||
database.execute("CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_j VALUES('jue', 'j', '决', 100);"
|
||
"INSERT INTO tbl_1_j VALUES('juan', 'j', '捐', 100);"
|
||
"INSERT INTO tbl_1_j VALUES('jun', 'j', '军', 100);");
|
||
database.execute("CREATE TABLE tbl_1_q(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_q VALUES('quan', 'q', '全', 100);"
|
||
"INSERT INTO tbl_1_q VALUES('qun', 'q', '群', 100);");
|
||
database.execute("CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_x VALUES('xu', 'x', '需', 100);"
|
||
"INSERT INTO tbl_1_x VALUES('xuan', 'x', '选', 100);"
|
||
"INSERT INTO tbl_1_x VALUES('xun', 'x', '巡', 100);");
|
||
database.execute("CREATE TABLE tbl_1_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_y VALUES('yuan', 'y', '原', 100);"
|
||
"INSERT INTO tbl_1_y VALUES('yun', 'y', '云', 100);");
|
||
database.execute("CREATE TABLE tbl_2_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_x VALUES('xu''an', 'xa', '虚案', 100);");
|
||
database.execute("CREATE TABLE tbl_1_e(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_e VALUES('e', 'e', '鹅', 50);");
|
||
database.execute("CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_n VALUES('nu''e', 'ne', '怒鹅', 30);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
|
||
// jqxy + van/vn are complete syllables, regardless of the autocorrection setting
|
||
const struct
|
||
{
|
||
const char *alias;
|
||
const char *canonical;
|
||
const char *word;
|
||
} aliases[] = {{"xvan", "xuan", "选"}, {"xvn", "xun", "巡"}, {"jvan", "juan", "捐"}, {"jvn", "jun", "军"},
|
||
{"qvan", "quan", "全"}, {"qvn", "qun", "群"}, {"yvan", "yuan", "原"}, {"yvn", "yun", "云"}};
|
||
for (const auto &test : aliases)
|
||
{
|
||
for (const auto mask : {0u, both})
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, false, paths);
|
||
type(session, test.alias);
|
||
require(session.get_pinyin_segmentation_with_cases() == test.alias && session.preedit() == test.alias,
|
||
"An umlaut alias must stay a single syllable with the original preedit.");
|
||
const auto index = candidate_index(session, test.word);
|
||
const auto &item = session.candidates()[index];
|
||
require(item.pinyin == test.canonical && item.corrected_from == test.alias &&
|
||
item.source == CandidateSource::Database,
|
||
"An umlaut alias must resolve to the canonical dictionary entry.");
|
||
require(session.select_candidate(index).commit == test.word && !session.has_composition(),
|
||
"Selecting an umlaut alias must consume the whole syllable.");
|
||
}
|
||
require(quanpin::to_google_spelling(test.alias) == test.canonical,
|
||
"Decoder spelling must canonicalize complete umlaut aliases.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, false, paths);
|
||
type(session, "xv'an");
|
||
require(session.preedit() == "xv'an" && find_candidate_index(session, "选") == session.candidates().size(),
|
||
"An explicit separator must not be merged into an umlaut syllable.");
|
||
require(session.select_candidate(candidate_index(session, "虚案")).commit == "虚案",
|
||
"Explicitly separated umlaut syllables must keep their dictionary reading.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, false, paths);
|
||
SentenceAssociationOptions association;
|
||
association.word_lattice = true;
|
||
session.set_sentence_association(association);
|
||
type(session, "xvanqvn");
|
||
const auto index = candidate_index(session, "选群");
|
||
require(session.candidates()[index].source == CandidateSource::Generated,
|
||
"The word lattice must compose canonical entries from umlaut aliases.");
|
||
require(session.select_candidate(index).commit == "选群" && !session.has_composition(),
|
||
"A composed umlaut phrase must consume both syllables.");
|
||
require(quanpin::to_google_spelling("xvan'qvn") == "xuan'qun",
|
||
"Decoder spelling must preserve syllable separators.");
|
||
}
|
||
|
||
// AC1 + preedit 非回归:nue 命中 nve 行并带标记,preedit 仍画原样字母。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nue");
|
||
require(session.get_pinyin_segmentation_with_cases() == "nue",
|
||
"The preedit for 'nue' must keep the typed letters, not the alias rewrite.");
|
||
require(session.preedit() == "nue", "The engine preedit for 'nue' must stay unrewritten.");
|
||
const auto found = find_candidate_index(session, "虐");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "nue",
|
||
"The 'nue' candidate for 虐 must carry corrected_from='nue'.");
|
||
require(found < session.candidates().size() && session.candidates()[found].pinyin == "nve",
|
||
"The 'nue' candidate must carry the canonical pinyin 'nve'.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nve");
|
||
const auto found = find_candidate_index(session, "虐");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from.empty(),
|
||
"The standard spelling 'nve' must stay unmarked.");
|
||
}
|
||
|
||
// AC2:lue/lve 同理。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "lue");
|
||
const auto found = find_candidate_index(session, "略");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "lue",
|
||
"The 'lue' candidate for 略 must carry corrected_from='lue'.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "lve");
|
||
const auto found = find_candidate_index(session, "略");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from.empty(),
|
||
"The standard spelling 'lve' must stay unmarked.");
|
||
}
|
||
|
||
// R2 例外:纠错全关时 ü 归一(非手误)仍然生效——nue -> nve、lve 直接命中。
|
||
// 只断言可达(nue 另有 nu'e 读法与它竞争,谁领衔由词频裁决,与开关无关)。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, true, paths);
|
||
type(session, "nue");
|
||
require(find_candidate_index(session, "虐") < session.candidates().size(),
|
||
"The ü alias rewrite must stay active for 'nue' with autocorrection off.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, true, paths);
|
||
type(session, "lve");
|
||
require(find_candidate_index(session, "略") < session.candidates().size(),
|
||
"The standard 'lve' spelling must stay reachable with autocorrection off.");
|
||
}
|
||
|
||
// AC3:jqxy 系既有归一从无标变带标(行为变更),标准拼法无标。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "jve");
|
||
require(session.get_pinyin_segmentation_with_cases() == "jve",
|
||
"The preedit for 'jve' must keep the typed letters.");
|
||
const auto found = find_candidate_index(session, "决");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "jve",
|
||
"The 'jve' candidate for 决 must carry corrected_from='jve'.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "jue");
|
||
const auto found = find_candidate_index(session, "决");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from.empty(),
|
||
"The standard spelling 'jue' must stay unmarked.");
|
||
}
|
||
|
||
// AC4:nu/nv/lu/lv 真实音节互不串,标准拼法均无标记。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nu");
|
||
require(find_candidate_index(session, "怒") < session.candidates().size() &&
|
||
find_candidate_index(session, "女") == session.candidates().size() &&
|
||
find_candidate_index(session, "虐") == session.candidates().size(),
|
||
"'nu' must only offer 怒, never 女/虐.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nv");
|
||
const auto found = find_candidate_index(session, "女");
|
||
require(found < session.candidates().size() &&
|
||
find_candidate_index(session, "怒") == session.candidates().size() &&
|
||
find_candidate_index(session, "虐") == session.candidates().size() &&
|
||
session.candidates()[found].corrected_from.empty(),
|
||
"'nv' must only offer unmarked 女, never 怒/虐.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "lu");
|
||
require(find_candidate_index(session, "路") < session.candidates().size() &&
|
||
find_candidate_index(session, "绿") == session.candidates().size() &&
|
||
find_candidate_index(session, "略") == session.candidates().size(),
|
||
"'lu' must only offer 路, never 绿/略.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "lv");
|
||
const auto found = find_candidate_index(session, "绿");
|
||
require(found < session.candidates().size() &&
|
||
find_candidate_index(session, "路") == session.candidates().size() &&
|
||
find_candidate_index(session, "略") == session.candidates().size() &&
|
||
session.candidates()[found].corrected_from.empty(),
|
||
"'lv' must only offer unmarked 绿, never 路/略.");
|
||
}
|
||
|
||
// AC5:手动分隔符 nu'e 切分为 怒+鹅,不触发别名(虐不可见),无标记。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nu'e");
|
||
require(find_candidate_index(session, "怒鹅") < session.candidates().size() &&
|
||
find_candidate_index(session, "怒") < session.candidates().size() &&
|
||
find_candidate_index(session, "虐") == session.candidates().size(),
|
||
"'nu'e' must split as 怒+鹅 and never trigger the nue alias.");
|
||
require(std::none_of(session.candidates().begin(), session.candidates().end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }),
|
||
"'nu'e' must produce no marked candidates.");
|
||
}
|
||
|
||
// AC6:别名命中的候选上屏后,调频数据落在标准拼法键 nve 上。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nue");
|
||
(void)session.select_candidate(find_candidate_index(session, "虐"));
|
||
require(!session.has_composition(), "Selecting the only candidate must finish the composition.");
|
||
Database database(directory / "msime.db");
|
||
require(database.query_integer("SELECT weight FROM tbl_1_n WHERE key='nve' AND value='虐'") == 101,
|
||
"Selecting 虐 from 'nue' must update the canonical 'nve' row.");
|
||
require(database.query_integer("SELECT COUNT(*) FROM tbl_1_n WHERE key='nue'") == 0,
|
||
"No user data may accumulate under the alias key 'nue'.");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
|
||
void run_autocorrect_mixed_jianpin_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const auto directory = data_directory / "autocorrect-mixed-jianpin";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_y VALUES('ying''wen', 'yw', '英文', 100);"
|
||
"INSERT INTO tbl_2_y VALUES('yu''wen', 'yw', '语文', 90);"
|
||
"CREATE TABLE tbl_1_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_w VALUES('wen', 'w', '问', 1000);"
|
||
"CREATE TABLE tbl_2_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_x VALUES('xue''sheng', 'xs', '学生', 100);"
|
||
"CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('sheng', 's', '生', 1000);"
|
||
"INSERT INTO tbl_1_s VALUES('shang', 's', '上', 1000);"
|
||
"CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_s VALUES('shi''shang', 'ss', '世上', 100);"
|
||
"CREATE TABLE tbl_3_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_y VALUES('ying''wen''hua', 'ywh', '英文化', 100);"
|
||
"CREATE TABLE tbl_2_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_w VALUES('wei''wen', 'ww', '慰问', 100);"
|
||
"INSERT INTO tbl_2_w VALUES('wen''hua', 'wh', '文化', 1000);");
|
||
}
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
struct Case
|
||
{
|
||
const char *input;
|
||
const char *word;
|
||
const char *segmentation;
|
||
};
|
||
const std::array cases{Case{"ywen", "英文", "y'wen"}, Case{"xsheng", "学生", "x'sheng"},
|
||
Case{"ywenhua", "英文化", "y'wen'hua"}, Case{"sshang", "世上", "s'shang"},
|
||
Case{"wwen", "慰问", "w'wen"}};
|
||
for (const unsigned mask : {0u, quanpin::kAutocorrectTransposition, quanpin::kAutocorrectNeighbor,
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor,
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor |
|
||
quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion})
|
||
{
|
||
for (const auto &test : cases)
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, false, paths);
|
||
type(session, test.input);
|
||
require(candidate_index(session, test.word) == 0,
|
||
"Autocorrection must not displace a mixed-jianpin candidate.");
|
||
require(session.candidates().front().corrected_from.empty(),
|
||
"A correctly typed mixed-jianpin candidate must not be marked as corrected.");
|
||
require(session.get_pinyin_segmentation_with_cases() == test.segmentation,
|
||
"The mixed-jianpin preedit must keep the typed syllable boundaries.");
|
||
if (std::string(test.input) == "ywen")
|
||
{
|
||
require(find_candidate_index(session, "语文") < session.candidates().size(),
|
||
"Autocorrection must retain mixed-jianpin alternatives.");
|
||
}
|
||
require(session.select_candidate(0).commit == test.word && session.preedit().empty(),
|
||
"Selecting a mixed-jianpin word must consume the original input.");
|
||
}
|
||
}
|
||
for (const char *typo : {"sahng", "shabg", "shng", "bqng", "hau", "gau", "uanli"})
|
||
{
|
||
require(!quanpin::looks_like_jianpin_spelling(typo),
|
||
"Malformed spellings must still be eligible for autocorrection.");
|
||
}
|
||
}
|
||
|
||
void run_literal_mixed_pinyin_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const auto directory = data_directory / "literal-mixed-pinyin";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_3_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_w VALUES('wei''shen''me','wsm','为什么',100);"
|
||
"CREATE TABLE tbl_2_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_w VALUES('wei''min','wm','为民',100000);"
|
||
"CREATE TABLE tbl_1_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_w VALUES('wei','w','为',100000);"
|
||
"CREATE TABLE tbl_3_k(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_k VALUES('kan''dian''ying','kdy','看电影',100);"
|
||
"INSERT INTO tbl_3_k VALUES('kan''dian''ying','kdy','看电映',90);"
|
||
"CREATE TABLE tbl_4_k(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_4_k VALUES('kan''dian''ying''qu','kdyq','看电影去',200);"
|
||
"CREATE TABLE tbl_2_k(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_k VALUES('kan''yang','ky','看样',100000);"
|
||
"INSERT INTO tbl_2_k VALUES('kan''yang','ky','看电影',90000);"
|
||
"INSERT INTO tbl_2_k VALUES('kan''dao','kd','看到',1000);"
|
||
"CREATE TABLE tbl_1_k(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_k VALUES('kan','k','看',100000);"
|
||
"CREATE TABLE tbl_4_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_4_g VALUES('gai''zen''me''ban','gzmb','该怎么办',100);"
|
||
"CREATE TABLE tbl_3_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_g VALUES('gai''zen''me','gzm','该怎么',1000);"
|
||
"CREATE TABLE tbl_1_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_g VALUES('gai','g','该',100000);"
|
||
"CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_b VALUES('ban','b','办',100);"
|
||
"INSERT INTO tbl_1_b VALUES('bang','b','帮',100);"
|
||
"CREATE TABLE tbl_3_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_z VALUES('zhe''yang''zi','zyz','这样子',100);"
|
||
"CREATE TABLE tbl_1_q(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_q VALUES('qu','q','去',1000);"
|
||
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('ni','n','你',1000);"
|
||
"CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_z VALUES('zhong','z','中',100);"
|
||
"INSERT INTO tbl_1_z VALUES('zhe','z','这',100000);");
|
||
}
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = paths.user_data = paths.cache = paths.dictionaries = directory;
|
||
struct Case
|
||
{
|
||
const char *key;
|
||
const char *word;
|
||
};
|
||
const std::array phrases{Case{"wei'shen'me", "为什么"}, Case{"kan'dian'ying", "看电影"},
|
||
Case{"gai'zen'me'ban", "该怎么办"}, Case{"zhe'yang'zi", "这样子"}};
|
||
struct Boundary
|
||
{
|
||
const char *input;
|
||
const char *code;
|
||
const char *reading;
|
||
unsigned expected;
|
||
};
|
||
const std::array boundaries{
|
||
Boundary{"kandy", "kan'd'y", "kan'dian'ying", 1},
|
||
Boundary{"zheyzi", "zhe'y'zi", "zhe'yang'zi", 1},
|
||
Boundary{"ad", "a'd", "a'de", 1},
|
||
Boundary{"gaizenmbq", "gai'zen'm'b", "gai'zen'me'ban", 2},
|
||
Boundary{"gaizenmbzh", "gai'zen'm'b", "gai'zen'me'ban", 2},
|
||
Boundary{"gaizenmbch", "gai'zen'm'b", "gai'zen'me'ban", 2},
|
||
Boundary{"gaizenmbsh", "gai'zen'm'b", "gai'zen'me'ban", 2},
|
||
Boundary{"kandianying", "kan'dian'ying", "kan'dian'ying", 0},
|
||
Boundary{"kdy", "k'd'y", "kan'dian'ying", 0},
|
||
Boundary{"kan'd'y", "kan'd'y", "kan'dian'ying", 0},
|
||
Boundary{"kanmy", "kan'd'y", "kan'dian'ying", 0},
|
||
Boundary{"kandy", "kan'd'y", "kan'ying", 0},
|
||
Boundary{"bqng", "b'q'n'g", "ba'qu'ni'gu", 0},
|
||
Boundary{"gaizenmban", "gai'zen'm", "gai'zen'me", 0},
|
||
Boundary{"gaizenmbq", "gai'zen'm", "gai'zen'me", 0},
|
||
Boundary{"gaizenmbzho", "gai'zen'm'b", "gai'zen'me'ban", 0},
|
||
Boundary{"kandy", "kan'd'y", "", 0},
|
||
};
|
||
for (const auto &test : boundaries)
|
||
require(quanpin::literal_mixed_pinyin_match(test.input, test.code, test.reading) == test.expected,
|
||
(std::string("Unexpected literal mixed-pinyin boundary: ") + test.input + "/" + test.code).c_str());
|
||
const auto check = [&](metasequoia::InputSession &session, const std::string &input, const Case &test,
|
||
const std::string &code, const std::string &tail = "") {
|
||
const auto &head = session.candidates().at(0);
|
||
if (head.word != test.word || head.canonical_pinyin != test.key || head.pinyin != code ||
|
||
!head.corrected_from.empty())
|
||
{
|
||
throw std::runtime_error("Original mixed-pinyin candidate lost its rank or metadata: " + input +
|
||
"; actual=" + head.word + "," + head.pinyin + "," + head.canonical_pinyin);
|
||
}
|
||
const auto expected_display = code + (tail.empty() ? "" : "'" + tail);
|
||
const auto display = session.get_pinyin_segmentation_with_cases();
|
||
if (input.find('\'') != std::string::npos)
|
||
{
|
||
auto letters = display;
|
||
letters.erase(std::remove(letters.begin(), letters.end(), '\''), letters.end());
|
||
auto expected_letters = code;
|
||
expected_letters.erase(std::remove(expected_letters.begin(), expected_letters.end(), '\''),
|
||
expected_letters.end());
|
||
require(session.raw_segmentation() == code && letters == expected_letters,
|
||
"Manual delimiters must preserve raw boundaries and all display letters.");
|
||
}
|
||
else if (display != expected_display)
|
||
throw std::runtime_error("Unexpected mixed-pinyin display: " + input + "; expected=" + expected_display +
|
||
"; actual=" + display);
|
||
session.set_candidate_learning_enabled(false);
|
||
require(session.select_candidate(0).commit == test.word && session.preedit() == tail,
|
||
"Mixed-pinyin selection must consume exactly its original letters.");
|
||
};
|
||
for (const unsigned mask : {0u, 1u, 2u, 3u})
|
||
{
|
||
// 8 + 8 + 16 + 8 spellings, including all-full and all-initial controls, under all four switches.
|
||
for (const auto &phrase : phrases)
|
||
{
|
||
const auto syllables = quanpin::split_segments(phrase.key);
|
||
for (size_t bits = 0; bits < (size_t{1} << syllables.size()); ++bits)
|
||
{
|
||
auto matched = syllables;
|
||
std::string input;
|
||
for (size_t i = 0; i < matched.size(); ++i)
|
||
{
|
||
if (bits & (size_t{1} << i))
|
||
matched[i].resize(1);
|
||
input += matched[i];
|
||
}
|
||
const auto code = quanpin::join_segments(matched);
|
||
for (const bool delimited : {false, true})
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, false, paths);
|
||
for (int repetition = 0; repetition < 2; ++repetition)
|
||
{
|
||
type(session, delimited ? code : input);
|
||
check(session, (delimited ? code : input) + "/mask=" + std::to_string(mask), phrase, code);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (const char *tail : {"q", "n", "zh", "ch", "sh"})
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, false, paths);
|
||
SentenceAssociationOptions association;
|
||
association.word_lattice = true;
|
||
session.set_sentence_association(association);
|
||
type(session, std::string("gaizenmb") + tail);
|
||
check(session, std::string("gaizenmb") + tail, phrases[2], "gai'zen'm'b", tail);
|
||
}
|
||
for (const auto &prefix : std::array{Case{"dy", "看"}, Case{"y", "看到"}, Case{"", "看电影"}})
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, false, paths);
|
||
type(session, "kand");
|
||
type(session, "y");
|
||
(void)session.handle_command(metasequoia::Command::Backspace);
|
||
type(session, "y");
|
||
session.set_candidate_learning_enabled(false);
|
||
require(session.select_candidate(candidate_index(session, prefix.word)).commit == prefix.word &&
|
||
session.preedit() == prefix.key,
|
||
"Selecting a literal prefix must not consume corrected spans.");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, false, paths);
|
||
type(session, "kandyq");
|
||
check(session, "kandyq", Case{"kan'dian'ying'qu", "看电影去"}, "kan'd'y'q");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, false, paths);
|
||
type(session, "gaizenmb");
|
||
session.set_candidate_learning_enabled(false);
|
||
require(session.select_candidate(candidate_index(session, "该怎么")).commit == "该怎么" &&
|
||
session.preedit() == "b" && session.select_candidate(0).commit == "办",
|
||
"Partial mixed-pinyin selection must retain the last initial.");
|
||
}
|
||
}
|
||
struct DoublePinyin
|
||
{
|
||
const ShuangpinProfile *profile;
|
||
std::array<const char *, 4> inputs;
|
||
};
|
||
const std::array double_pinyin{
|
||
DoublePinyin{&GetXiaoheShuangpinProfile(), {"wwufme", "kjdmyk", "gdzfmebj", "veyhzi"}},
|
||
DoublePinyin{&GetZiranmaShuangpinProfile(), {"wzufme", "kjdmyy", "glzfmebj", "veyhzi"}},
|
||
DoublePinyin{&GetShoudaoShuangpinProfile(), {"wmekme", "kjdnyg", "glzkmebj", "veyyzi"}},
|
||
DoublePinyin{&GetMicrosoftShuangpinProfile(), {"wzufme", "kjdmy;", "glzfmebj", "veyhzi"}},
|
||
DoublePinyin{&GetSogouShuangpinProfile(), {"wzufme", "kjdmy;", "glzfmebj", "veyhzi"}},
|
||
DoublePinyin{&GetZiguangShuangpinProfile(), {"wkiwme", "krdfy;", "gpzwmebr", "ueyszi"}},
|
||
DoublePinyin{&GetZhinengAbcShuangpinProfile(), {"wqvfme", "kjdwyy", "glzfmebj", "aeyhzi"}},
|
||
DoublePinyin{&GetGuobiaoShuangpinProfile(), {"wburme", "kfddyj", "gkzrmebf", "veygzi"}},
|
||
DoublePinyin{&GetPinyinJiajiaShuangpinProfile(), {"wwirme", "kfdjyq", "gszrmebf", "veygzi"}},
|
||
};
|
||
const auto type_double_pinyin = [](metasequoia::InputSession &session, const std::string &input) {
|
||
for (const char ch : input)
|
||
session.handle_engine_key(ch == ';' ? 0xBAu : static_cast<unsigned>(ch - 'a' + 'A'), 0, ch);
|
||
};
|
||
for (const auto &layout : double_pinyin)
|
||
{
|
||
for (const unsigned mask : {0u, 1u, 2u, 3u})
|
||
{
|
||
for (size_t i = 0; i < phrases.size(); ++i)
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Shuangpin, *layout.profile, paths);
|
||
session.set_shuangpin_helpcode_enabled(false);
|
||
session.set_candidate_learning_enabled(false);
|
||
session.set_quanpin_autocorrect_types(mask);
|
||
for (int repetition = 0; repetition < 2; ++repetition)
|
||
{
|
||
type_double_pinyin(session, layout.inputs[i]);
|
||
require(!session.candidates().empty() && session.candidates().front().word == phrases[i].word &&
|
||
session.candidates().front().canonical_pinyin == phrases[i].key &&
|
||
session.candidates().front().corrected_from.empty() &&
|
||
session.normalized_segmentation() == phrases[i].key,
|
||
("Mixed-pinyin ordering must preserve Shuangpin: " + layout.profile->name + "/" +
|
||
layout.inputs[i] + "/mask=" + std::to_string(mask))
|
||
.c_str());
|
||
require(session.select_candidate(0).commit == phrases[i].word && session.preedit().empty(),
|
||
"Shuangpin selection must consume exactly its two-key syllables.");
|
||
}
|
||
}
|
||
metasequoia::InputSession partial(SchemeType::Shuangpin, *layout.profile, paths);
|
||
partial.set_shuangpin_helpcode_enabled(false);
|
||
partial.set_candidate_learning_enabled(false);
|
||
partial.set_quanpin_autocorrect_types(mask);
|
||
type_double_pinyin(partial, layout.inputs[1]);
|
||
require(partial.select_candidate(candidate_index(partial, "看")).commit == "看" &&
|
||
partial.preedit() == std::string(layout.inputs[1]).substr(2),
|
||
"A Shuangpin prefix selection must retain the remaining two-key units.");
|
||
}
|
||
}
|
||
{
|
||
metasequoia::InputSession switching(SchemeType::Quanpin, 3u, false, true, false, paths);
|
||
type(switching, "kandy");
|
||
check(switching, "kandy before scheme switch", phrases[1], "kan'd'y");
|
||
switching.switch_scheme(SchemeType::Shuangpin);
|
||
switching.set_shuangpin_helpcode_enabled(false);
|
||
type_double_pinyin(switching, "kjdmyk");
|
||
require(switching.select_candidate(0).commit == "看电影" && switching.preedit().empty(),
|
||
"Switching to Shuangpin must discard Quanpin candidate state.");
|
||
switching.switch_scheme(SchemeType::Quanpin);
|
||
type(switching, "zheyzi");
|
||
check(switching, "zheyzi after scheme switch", phrases[3], "zhe'y'zi");
|
||
}
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 3u, false, true, false, paths);
|
||
for (const char *input : {"kandy", "kanwy", "kandy"})
|
||
{
|
||
type(session, input);
|
||
metasequoia::InputSession fresh(SchemeType::Quanpin, 3u, false, true, false, paths);
|
||
type(fresh, input);
|
||
require(same_candidate_words(session, fresh), "A correction cache must not leak another input's literal rows.");
|
||
session.reset_state();
|
||
}
|
||
type(session, "kandy");
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("UPDATE tbl_3_k SET weight=200 WHERE value='看电映';");
|
||
}
|
||
session.reset_cache();
|
||
session.recompute_candidates();
|
||
require(session.candidates().front().word == "看电映", "Literal candidates must retain their own frequency order.");
|
||
session.set_quanpin_autocorrect_types(0);
|
||
session.recompute_candidates();
|
||
require(session.candidates().front().word == "看电映",
|
||
"Changing correction switches must refresh candidate caches.");
|
||
session.set_quanpin_autocorrect_types(3);
|
||
session.set_fuzzy_pinyin_options({static_cast<std::uint32_t>(metasequoia::FuzzyPinyinRule::IAN_IANG)});
|
||
SentenceAssociationOptions association;
|
||
association.word_lattice = true;
|
||
session.set_sentence_association(association);
|
||
session.set_rescoring_context("请");
|
||
session.recompute_candidates();
|
||
require(session.candidates().front().word == "看电映" && session.candidates().front().corrected_from.empty(),
|
||
"Fuzzy/context reranking must retain original mixed-pinyin priority and metadata.");
|
||
require(session.pin_candidate(candidate_index(session, "看电影")).handled &&
|
||
session.candidates().front().word == "看电影",
|
||
"Explicit frequency pinning must still reorder words within the literal group.");
|
||
session.enable_fixed_positions();
|
||
// 用「看到」(输入字面就能命中的混合简拼候选)。此前这里用「看样」——它靠
|
||
// 生成式远键插入 kand->kan 与简拼尾 y 组合出 kan'y 才出现;本任务移除远键
|
||
// 插入后该读法不再存在,改用一个不依赖它的候选验固定位置优先级。
|
||
require(session.set_candidate_position(candidate_index(session, "看到"), 1).handled &&
|
||
session.candidates().front().word == "看到",
|
||
"Explicit fixed positions must take precedence over the default mixed-pinyin order.");
|
||
metasequoia::InputSession cased(SchemeType::Quanpin, 3u, false, true, false, paths);
|
||
cased.handle_engine_key('K', 0, 'K');
|
||
type(cased, "andy");
|
||
require(cased.get_pinyin_segmentation_with_cases() == "Kan'd'y",
|
||
"The literal mixed-pinyin preedit must preserve the original letter cases.");
|
||
for (const char *typo : {"zhogn", "zhng", "bqng"})
|
||
{
|
||
metasequoia::InputSession corrected(SchemeType::Quanpin, 3u, false, true, false, paths);
|
||
type(corrected, typo);
|
||
require(candidate_index(corrected, std::string(typo) == "bqng" ? "帮" : "中") < corrected.candidates().size(),
|
||
"True typos must still produce correction candidates.");
|
||
}
|
||
}
|
||
|
||
// 光标驱动的前缀解码(PRD R2–R7,Stage 1):候选与量化边界按「光标之前的完整音节
|
||
// 单元前缀」重算。自建隔离词库,不与主 fixture 互相污染;Server(Stage 2)将以
|
||
// set_caret + recompute_candidates 的同一方式消费这些入口。
|
||
// 阶段 1 上下文消解(任务 quanpin-autocorrect-context-ranking):同档纠错读法
|
||
// 在词格路径分边际达标时由胜出切分接管领衔。zhng 的删除目标按表序 zhang 在前
|
||
// (主切),fixture 让 zheng 侧的办证权重高 20 倍——启发式路径分差 ln(20)=3.0
|
||
// (log10 1.3),两种标度下都过 1.0 接管边际。fixture 无 sc.lm,词格退回
|
||
// 启发式打分(ln(weight)+词长奖励),权重完全决定路径分,测试因此确定。
|
||
void run_autocorrect_context_ranking_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "autocorrect-context";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '办证', 100000);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '辩证', 10);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '班长', 5000);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '搬账', 4000);");
|
||
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('shang', 's', '上', 900);"
|
||
"INSERT INTO tbl_1_s VALUES('sheng', 's', '生', 800);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
SentenceAssociationOptions lattice_only;
|
||
lattice_only.word_lattice = true;
|
||
|
||
const auto candidate_words = [](const metasequoia::InputSession &session) {
|
||
std::vector<std::string> words;
|
||
for (const auto &item : session.candidates())
|
||
{
|
||
words.push_back(item.word);
|
||
}
|
||
return words;
|
||
};
|
||
|
||
// 关联关闭 = 现状静态路径:合并池按权重排序,办证系与班长系交错。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "banzhng");
|
||
const auto words = candidate_words(session);
|
||
require(words.size() == 4 && words[0] == "办证" && words[1] == "班长" && words[2] == "搬账" &&
|
||
words[3] == "辩证",
|
||
"The static same-tier merge must interleave the readings by dictionary weight.");
|
||
}
|
||
|
||
// 关联开启:zheng 切分上下文胜出接管领衔——办证系整体前移,辩证从末位
|
||
// 升到第 2 位,班长系整体后移。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
session.set_sentence_association(lattice_only);
|
||
type(session, "banzhng");
|
||
const auto words = candidate_words(session);
|
||
require(words.size() == 4 && words[0] == "办证" && words[1] == "辩证" && words[2] == "班长" &&
|
||
words[3] == "搬账",
|
||
"The context-ranked reading must lead with its whole word group.");
|
||
}
|
||
|
||
// 单音节纠错无词格可搭(词格要求 >=2 完整音节):两种状态必须逐位一致。
|
||
{
|
||
metasequoia::InputSession off(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(off, "shng");
|
||
metasequoia::InputSession on(SchemeType::Quanpin, both, true, true, true, paths);
|
||
on.set_sentence_association(lattice_only);
|
||
type(on, "shng");
|
||
require(same_candidate_words(off, on),
|
||
"A single-syllable correction must take the static path even with the word lattice on.");
|
||
require(candidate_words(on).size() == 2, "The single-syllable fixture must surface both deletion targets.");
|
||
}
|
||
}
|
||
|
||
// 审阅缺陷 D1(PR #592 行内评论):上下文重排接管后,胜出切分的单字前缀曾被插到
|
||
// 同键位整词前面。query_series 的 count 从整键递减到 1,ban'zheng 的输出里除了
|
||
// 办证/辩证/整句还带着首音节的单字;这些单字排在 rest(班长、搬账)之前,把同键位的
|
||
// 其他整词整组挤出首页。原 fixture 没有 tbl_1_b,前缀这条路根本没被走到。
|
||
// 这里补单字表,并直接断言「整键在前、前缀在后」这条分层不变量。
|
||
// 同上一组 fixture:词库目录里没有 sc.lm,词格退回启发式打分,权重完全决定路径分,
|
||
// 因此 zheng 侧稳定胜出、重排稳定接管,用例不依赖任何模型文件。
|
||
void run_autocorrect_context_layering_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "autocorrect-context-layering";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '办证', 100000);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '辩证', 10);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '班长', 5000);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '搬账', 4000);");
|
||
// 单字表:query_series 递减到 count==1 时查它,前缀行由此产生。单字权重取 1e6
|
||
// 量级(真实词库里单字是语料计数),确保它们不会被 append 阶段的去重或调频挪走。
|
||
database.execute("CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_b VALUES('ban', 'b', '办', 1000000);"
|
||
"INSERT INTO tbl_1_b VALUES('ban', 'b', '半', 900000);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
SentenceAssociationOptions lattice_only;
|
||
lattice_only.word_lattice = true;
|
||
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
session.set_sentence_association(lattice_only);
|
||
type(session, "banzhng");
|
||
|
||
const auto &items = session.candidates();
|
||
const auto index_of = [&items](const std::string &word) {
|
||
for (size_t i = 0; i < items.size(); ++i)
|
||
{
|
||
if (items[i].word == word)
|
||
{
|
||
return i;
|
||
}
|
||
}
|
||
return items.size();
|
||
};
|
||
|
||
// 先确认 fixture 真的同时产出了整键行与单字前缀行,否则下面的分层断言是空的。
|
||
const size_t prefix_a = index_of("办");
|
||
const size_t prefix_b = index_of("半");
|
||
require(prefix_a != items.size() && prefix_b != items.size(),
|
||
"The layering fixture must surface single-character prefix rows for the winner cut.");
|
||
const size_t sibling = index_of("班长");
|
||
require(sibling != items.size(), "The layering fixture must surface the same-tier whole word.");
|
||
|
||
require(index_of("办证") < prefix_a, "The winner cut's whole word must precede its own single-character prefixes.");
|
||
require(sibling < prefix_a && sibling < prefix_b,
|
||
"Same-tier whole words must not be pushed behind the winner cut's single-character prefixes.");
|
||
|
||
// 分层的完整形式:所有整键行都在任何前缀行之前。全拼键用 ' 分隔音节,
|
||
// 单音节键不含 '。
|
||
const auto is_prefix_row = [&items](size_t i) { return items[i].canonical_pinyin.find('\'') == std::string::npos; };
|
||
bool seen_prefix = false;
|
||
for (size_t i = 0; i < items.size(); ++i)
|
||
{
|
||
if (is_prefix_row(i))
|
||
{
|
||
seen_prefix = true;
|
||
}
|
||
else
|
||
{
|
||
require(!seen_prefix, "Every whole-key row must precede every single-character prefix row.");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 用户在主切读音上的选择优先于上下文接管。加载 LM 时路径分只剩 dictionary_tiebreak
|
||
// 一点点词库权重,调频翻不过接管边际,所以前两组 fixture 的启发式打分测不出来:这里
|
||
// 放一份手写 ARPA 当 sc.lm(kenlm 按文本格式载入),让 LM 稳定偏向办证(log10 差 2.5),
|
||
// 同时把班长的词库权重设成调频后的样子——越过办证。
|
||
void run_autocorrect_context_user_choice_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const auto make_fixture = [&data_directory](const char *name, int banzhang_weight) {
|
||
const std::filesystem::path directory = data_directory / name;
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '办证', 100000);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zheng', 'bz', '辩证', 10);"
|
||
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '搬账', 4000);");
|
||
const std::string banzhang =
|
||
"INSERT INTO tbl_2_b VALUES('ban''zhang', 'bz', '班长', " + std::to_string(banzhang_weight) + ");";
|
||
database.execute(banzhang.c_str());
|
||
}
|
||
// 二进制写:文本模式在 Windows 上会把换行写成 \r\n,kenlm 的 ARPA 解析不认。
|
||
std::ofstream arpa(directory / metasequoia::assets::language_model, std::ios::binary);
|
||
arpa << "\\data\\\n"
|
||
"ngram 1=7\n"
|
||
"ngram 2=1\n"
|
||
"\n"
|
||
"\\1-grams:\n"
|
||
"-1.0\t<unk>\t0\n"
|
||
"-99\t<s>\t0\n"
|
||
"-1.0\t</s>\t0\n"
|
||
"-1.0\t办证\t0\n"
|
||
"-3.5\t班长\t0\n"
|
||
"-3.5\t搬账\t0\n"
|
||
"-4.0\t辩证\t0\n"
|
||
"\n"
|
||
"\\2-grams:\n"
|
||
"-0.5\t<s> 办证\n"
|
||
"\n"
|
||
"\\end\\\n";
|
||
require(static_cast<bool>(arpa), "Failed to write the user-choice language model fixture.");
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
return paths;
|
||
};
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
SentenceAssociationOptions lattice_only;
|
||
lattice_only.word_lattice = true;
|
||
const auto first_word = [both, &lattice_only](const metasequoia::RuntimePaths &paths) {
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
session.set_sentence_association(lattice_only);
|
||
type(session, "banzhng");
|
||
require(!session.candidates().empty(), "The user-choice fixture produced no candidates.");
|
||
return session.candidates().front().word;
|
||
};
|
||
|
||
{
|
||
const auto paths = make_fixture("autocorrect-context-user-choice", 150000);
|
||
// 没有用户记录:班长权重再高也只是出货词频,仍交给上下文裁决。这一步同时确认
|
||
// ARPA 真的载入了——启发式打分下班长权重更高,不会是办证领衔。
|
||
require(first_word(paths) == "办证",
|
||
"Without a user record the language model must still let the context reading lead.");
|
||
|
||
require(user_dictionary::record_upsert(metasequoia::path_to_utf8(paths.user(metasequoia::assets::user_journal)),
|
||
user_dictionary::DictionaryKind::Pinyin, "ban'zhang", "班长", 150000),
|
||
"Failed to write the user-choice journal record.");
|
||
require(first_word(paths) == "班长",
|
||
"A word the user ranked above the context winner must not be displaced by the context takeover.");
|
||
}
|
||
|
||
{
|
||
// 用户动过主切的键,但主切首位仍低于胜出切分首位:用户并没有把这组读音选到前面,照常接管。
|
||
const auto paths = make_fixture("autocorrect-context-user-touched", 5000);
|
||
require(user_dictionary::record_upsert(metasequoia::path_to_utf8(paths.user(metasequoia::assets::user_journal)),
|
||
user_dictionary::DictionaryKind::Pinyin, "ban'zhang", "搬账", 4000),
|
||
"Failed to write the user-touched journal record.");
|
||
require(first_word(paths) == "办证",
|
||
"A user record that does not outrank the context winner must not block the takeover.");
|
||
}
|
||
}
|
||
|
||
// 合法输入上的手误:ziazheliya 切成 zi'a'zhe'li'ya、jioa 切成 ji'o'a、niohao 切成
|
||
// ni'o'hao,每一段都合法,纠错入口的闸原本把它们整个挡在外面。现在按噪声信道
|
||
// 整句打分:词格路径分 - 每处纠正的代价(换位 2.0,多字/漏字 3.0),原读法代价 0。
|
||
// fixture 无 sc.lm,词格走启发式
|
||
// (单字 ln(w/1e6),词组 ln(w)+3×音节数),下面每组的分数都按它算好写在注释里。
|
||
void run_legal_input_correction_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const auto readings = [](const std::string &pinyin, const quanpin::Segments &segments, unsigned types) {
|
||
std::vector<std::string> keys;
|
||
for (const auto &cut : quanpin::legal_input_correction_cuts(pinyin, segments, types, 3))
|
||
{
|
||
quanpin::Segments syllables;
|
||
for (const auto &segment : cut.segments)
|
||
{
|
||
syllables.push_back(segment.syllable);
|
||
}
|
||
keys.push_back(quanpin::join_segments(syllables));
|
||
}
|
||
return keys;
|
||
};
|
||
const auto offers = [](const std::vector<std::string> &keys, const char *key) {
|
||
return std::find(keys.begin(), keys.end(), key) != keys.end();
|
||
};
|
||
const unsigned transposition = quanpin::kAutocorrectTransposition;
|
||
require(offers(readings("ziazheliya", {"zi", "a", "zhe", "li", "ya"}, transposition), "zai'zhe'li'ya"),
|
||
"A transposition inside a longer sentence must still offer the corrected reading.");
|
||
require(offers(readings("jioa", {"ji", "o", "a"}, transposition), "jiao"), "jioa must read as jiao.");
|
||
require(offers(readings("nia", {"ni", "a"}, transposition), "nai"), "nia must read as nai.");
|
||
require(offers(readings("liazheli", {"lia", "zhe", "li"}, transposition), "lai'zhe'li"),
|
||
"A rare legal syllable must offer its common transposition.");
|
||
require(readings("lia", {"lia"}, transposition).empty(),
|
||
"A single rare syllable has no context and must stay uncorrected.");
|
||
require(
|
||
offers(readings("niohao", {"ni", "o", "hao"}, quanpin::kAutocorrectInsertion), "ni'hao"),
|
||
"An extra QWERTY-neighbor letter that splits off a zero-initial syllable must offer the corrected reading.");
|
||
require(readings("ziazheli", {"zi", "a", "zhe", "li"}, quanpin::kAutocorrectNeighbor).empty(),
|
||
"Neighbor substitutions must stay off on legal input.");
|
||
require(
|
||
readings("nihao", {"ni", "hao"}, transposition | quanpin::kAutocorrectInsertion | quanpin::kAutocorrectDeletion)
|
||
.empty(),
|
||
"An ordinary legal input must not produce correction readings.");
|
||
|
||
const std::filesystem::path directory = data_directory / "legal-input-correction";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_z VALUES('zi', 'z', '字', 800000);"
|
||
"INSERT INTO tbl_1_z VALUES('zai', 'z', '在', 900000);"
|
||
"INSERT INTO tbl_1_z VALUES('zhe', 'z', '这', 900000);");
|
||
database.execute("CREATE TABLE tbl_3_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_z VALUES('zai''zhe''li', 'zzl', '在这里', 5000);");
|
||
database.execute("CREATE TABLE tbl_1_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_a VALUES('a', 'a', '啊', 600000);");
|
||
database.execute("CREATE TABLE tbl_1_o(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_o VALUES('o', 'o', '哦', 300000);");
|
||
database.execute("CREATE TABLE tbl_1_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_y VALUES('ya', 'y', '呀', 500000);"
|
||
"INSERT INTO tbl_1_y VALUES('yang', 'y', '养', 500000);");
|
||
database.execute("CREATE TABLE tbl_1_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_l VALUES('li', 'l', '里', 900000);"
|
||
"INSERT INTO tbl_1_l VALUES('lia', 'l', '俩', 500000);"
|
||
"INSERT INTO tbl_1_l VALUES('lai', 'l', '来', 900000);");
|
||
database.execute("CREATE TABLE tbl_3_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_l VALUES('lai''zhe''li', 'lzl', '来这里', 3000);");
|
||
database.execute("CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_j VALUES('jiao', 'j', '叫', 2000000);"
|
||
"INSERT INTO tbl_1_j VALUES('jiao', 'j', '教', 1500000);"
|
||
"INSERT INTO tbl_1_j VALUES('ji', 'j', '几', 800000);"
|
||
"INSERT INTO tbl_1_j VALUES('jiu', 'j', '就', 900000);");
|
||
database.execute("CREATE TABLE tbl_3_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_j VALUES('jiu''zhe''yang', 'jzy', '就这样', 5000);");
|
||
database.execute("CREATE TABLE tbl_1_e(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_e VALUES('e', 'e', '恶', 300000);");
|
||
database.execute("CREATE TABLE tbl_2_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_x VALUES('xie''e', 'xe', '邪恶', 1000);");
|
||
database.execute("CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_x VALUES('xie', 'x', '写', 2000000);"
|
||
"INSERT INTO tbl_1_x VALUES('xie', 'x', '些', 1500000);");
|
||
database.execute("CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_s VALUES('shu''e', 'se', '数额', 1000);");
|
||
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('she', 's', '社', 2000000);"
|
||
"INSERT INTO tbl_1_s VALUES('shu', 's', '数', 900000);");
|
||
database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '你', 900000);"
|
||
"INSERT INTO tbl_1_n VALUES('nai', 'n', '奶', 900000);");
|
||
database.execute("CREATE TABLE tbl_3_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_w VALUES('wo''men''lia', 'wml', '我们俩', 2000);"
|
||
"INSERT INTO tbl_3_w VALUES('wo''men''lai', 'wml', '我们来', 2500);");
|
||
database.execute("CREATE TABLE tbl_2_w(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_w VALUES('wo''men', 'wm', '我们', 3000);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
SentenceAssociationOptions lattice_only;
|
||
lattice_only.word_lattice = true;
|
||
|
||
const auto first_two = [](const metasequoia::InputSession &session, const char *first, const char *second) {
|
||
return session.candidates().size() >= 2 && session.candidates()[0].word == first &&
|
||
session.candidates()[1].word == second;
|
||
};
|
||
|
||
// 句中纠错:在这里呀 = 在这里(ln5000+9) + 呀(ln0.5) - 2 = 14.8,字啊这里呀全是单字
|
||
// = -1.6。纠错领衔,原读法的整句紧跟在第 2 位;预编辑按领衔读法分隔。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
session.set_sentence_association(lattice_only);
|
||
type(session, "ziazheliya");
|
||
require(first_two(session, "在这里呀", "字啊这里呀"),
|
||
"ziazheliya must lead with 在这里呀 and keep the uncorrected sentence second.");
|
||
require(session.candidates()[0].corrected_from == "ziazheliya",
|
||
"The legal-input correction must carry the corrected_from mark.");
|
||
require(session.candidates()[1].corrected_from.empty(), "The uncorrected reading must stay unmarked.");
|
||
require(session.get_pinyin_segmentation_with_cases() == "zia'zhe'li'ya",
|
||
"The preedit must follow the leading corrected reading.");
|
||
|
||
// 选纠错读法里的前缀词:换位不改字母数,消耗 ziazheli 八个字母,剩下 ya。
|
||
(void)session.select_candidate(candidate_index(session, "在这里"));
|
||
require(session.has_composition() && session.preedit() == "ya",
|
||
"Selecting a corrected prefix must consume exactly the letters it covers.");
|
||
}
|
||
// 整句开关关着照样纠:判断读音不看显示开关,只是首选退回整键/前缀词。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "ziazheliya");
|
||
require(first_two(session, "在这里", "字"),
|
||
"With sentence candidates off the corrected prefix word must still lead.");
|
||
}
|
||
|
||
// jiao 叫 = ln2 - 2 = -1.3,几哦啊 = ln0.8 + ln0.3 + ln0.6 = -1.9:纠错领衔。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "jioa");
|
||
require(first_two(session, "叫", "几"), "jioa must lead with jiao and keep the uncorrected reading second.");
|
||
require(find_candidate_index(session, "教") < session.candidates().size(),
|
||
"The rest of the corrected reading must follow.");
|
||
require(session.get_pinyin_segmentation_with_cases() == "jioa",
|
||
"A single corrected syllable must drop the literal ji'o'a separators.");
|
||
}
|
||
// 多打一个邻键元音(o 是 i 的邻键):zaiozheli 字面切成 zai'o'zhe'li 全合法,
|
||
// 纠错读法 zai'zhe'li 命中「在这里」并带 corrected_from(双击重复键已不算多字
|
||
// 纠错,见 test_pinyin 的 shangg 断言;这里改用邻键插入验长度改变读法的会话链路)。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "zaiozheli");
|
||
const auto found = find_candidate_index(session, "在这里");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "zaiozheli",
|
||
"A neighbor-inserted vowel must still reach the corrected phrase end to end.");
|
||
const auto committed = session.select_candidate(found);
|
||
require(committed.commit == "在这里" && !session.has_composition(),
|
||
"Selecting the neighbor-insertion corrected phrase must consume every typed letter.");
|
||
}
|
||
|
||
// 字面切分是真词(数额)时不认改变字母数的读法:shue 按多一个 u 读成 she 的「社」
|
||
// 不出现——少一个音节的句子在真实语言模型里常常分更高,会稳定挂在第 2 位。候选里
|
||
// 不应有任何纠错标记(社 作为 shu 的前缀单字以外不该出现)。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "shue");
|
||
require(session.candidates().front().word == "数额" &&
|
||
std::none_of(session.candidates().begin(), session.candidates().end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }),
|
||
"A length-changing correction must not be offered over a literal dictionary word.");
|
||
}
|
||
// 被裁掉的纠错读法不能波及原读法:xiee 按多一个 e 读成 xie 被丢掉之后,邪恶 后面的
|
||
// 前缀单字 写/些 读音也是 xie,曾因此全被标成纠错。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "xiee");
|
||
require(!session.candidates().empty() && session.candidates().front().word == "邪恶" &&
|
||
find_candidate_index(session, "写") < session.candidates().size() &&
|
||
std::none_of(session.candidates().begin(), session.candidates().end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }),
|
||
"A discarded correction reading must not mark the literal reading's prefix characters.");
|
||
}
|
||
|
||
// 罕见音节:来这里 = ln3000+9-2 = 15.0,俩这里 = -0.9。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "liazheli");
|
||
require(first_two(session, "来这里", "俩"),
|
||
"liazheli must lead with 来这里 and keep the uncorrected reading second.");
|
||
}
|
||
|
||
// 没有上下文时原读法占优:你啊 = ln0.9 + ln0.6 = -0.6,奶 = ln0.9 - 2 = -2.1。
|
||
// 原读法领衔,分差 1.5 在 3.0 以内,纠错读法占第 2 位。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nia");
|
||
require(first_two(session, "你", "奶"), "nia must keep 你 first and offer 奶 second.");
|
||
require(session.get_pinyin_segmentation_with_cases() == "ni'a",
|
||
"The preedit must keep the literal reading while it leads.");
|
||
}
|
||
|
||
// 两边都是整词:我们俩 = ln2000+9 = 16.6,我们来 = ln2500+9-2 = 14.8。原读法领衔,
|
||
// 纠错第 2 位。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "womenlia");
|
||
require(first_two(session, "我们俩", "我们来"),
|
||
"A close correction must take the second slot under the leading typed reading.");
|
||
}
|
||
// 纠错读法远远落后(我们来 降到 20:最好的路径 我们+来 = 11.9,差 4.7 > 3.0):不出现。
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("UPDATE tbl_3_w SET weight = 20 WHERE value = '我们来';");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "womenlia");
|
||
require(session.candidates().front().word == "我们俩" &&
|
||
find_candidate_index(session, "我们来") == session.candidates().size(),
|
||
"A correction far behind the typed reading must not appear at all.");
|
||
}
|
||
// 纠错读法分数更高(我们来 = ln50000+9-2 = 17.8 > 16.6),但字面切分本身就是词库
|
||
// 里的真词:首位留给它,纠错只排第 2。
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("UPDATE tbl_3_w SET weight = 50000 WHERE value = '我们来';");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "womenlia");
|
||
require(first_two(session, "我们俩", "我们来"),
|
||
"A correction must never displace a literal dictionary word from the first slot.");
|
||
}
|
||
|
||
// 同一条规则也管已有的辅音复制别名:yongan 被读成 yong'gan,勇敢权重再高也不能压过
|
||
// 字面整词永安。同一串字母的另一种切分不受影响(见 fangan 的方案/反感)。
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_y VALUES('yong''an', 'ya', '永安', 100);"
|
||
"INSERT INTO tbl_2_y VALUES('yong''gan', 'yg', '勇敢', 100000);");
|
||
}
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "yongan");
|
||
require(session.candidates().front().word == "永安",
|
||
"An alias reading that rewrites the letters must not displace the literal dictionary word.");
|
||
}
|
||
|
||
// 纠错关闭:合法输入照原样查。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, true, paths);
|
||
type(session, "ziazheli");
|
||
require(find_candidate_index(session, "在这里") == session.candidates().size(),
|
||
"The legal-input correction must stay off with autocorrection disabled.");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
|
||
// 贵档读法在上下文明显更好时可以领衔:sahngna 的换位读法 sahng->shang(权重 10,
|
||
// 上+拿)比邻键插入读法 sahng->sang(权重 12,桑拿)便宜一档,按旧规则贵档永远排在
|
||
// 最后。fixture 无 sc.lm,启发式打分:桑拿(ln10000+6) - 2.4 = 15.2,上+拿全是单字
|
||
// = ln0.9 + ln0.9 - 2.0 = -1.8。(双击/远键插入已随 R1 移除,本用例改用邻键插入。)
|
||
void run_autocorrect_costlier_context_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "autocorrect-costlier-context";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('shang', 's', '上', 900000);"
|
||
"INSERT INTO tbl_1_s VALUES('sang', 's', '桑', 900000);");
|
||
database.execute("CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_s VALUES('sang''na', 'sn', '桑拿', 10000);");
|
||
database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('na', 'n', '拿', 900000);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
SentenceAssociationOptions lattice_only;
|
||
lattice_only.word_lattice = true;
|
||
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
session.set_sentence_association(lattice_only);
|
||
type(session, "sahngna");
|
||
require(!session.candidates().empty() && session.candidates().front().word == "桑拿",
|
||
"A costlier neighbor-insertion correction the context clearly prefers must lead.");
|
||
require(session.get_pinyin_segmentation_with_cases() == "sahng'na",
|
||
"The preedit must follow the promoted costlier correction.");
|
||
}
|
||
// 只开万象来源行、不显示 Trigram 整句(用户实际配置):上下文消解照样生效。fixture
|
||
// 没有 .gram,出不了〔万象〕整句行,贵档胜者由它的最长前缀词「桑拿」领衔。
|
||
{
|
||
SentenceAssociationOptions collocation_only;
|
||
collocation_only.collocation_rerank = true;
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
session.set_sentence_association(collocation_only);
|
||
type(session, "sahngna");
|
||
require(!session.candidates().empty() && session.candidates().front().word == "桑拿",
|
||
"The context rerank must run whenever any sentence source is on, not only the Trigram row.");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
|
||
// 纠错开关关掉后,查询之外的几条路径也必须按字面切分,否则它们和查询落在不同的键上:
|
||
// - 写回:选中后调频要把字面键 hao'yong'a 的行改掉,而不是别名重切出的 hao'yong'ga;
|
||
// - 辅助码:带辅助码的输入自己切底串,不能让 haoyonga 经别名层变成 hao'yong'ga(辅助码只在底串是完整拼音时生效);
|
||
// - 固定位置:laing 的上下文 key 不能被切成 liang,和真正输入 liang 的那组混到一起。
|
||
void run_autocorrect_off_consistency_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "autocorrect-off-consistency";
|
||
std::filesystem::create_directories(directory);
|
||
write_file(directory / "helpcodes" / "helpcode.txt", "好=ef\n用=gh\n啊=ij\n阿=kl\n");
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_3_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_h VALUES('hao''yong''a', 'hya', '好用啊', 100);"
|
||
"INSERT INTO tbl_3_h VALUES('hao''yong''a', 'hya', '好用阿', 200);"
|
||
"INSERT INTO tbl_3_h VALUES('hao''yong''ga', 'hyg', '好用嘎', 300);");
|
||
|
||
database.execute("CREATE TABLE tbl_1_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_l VALUES('lai', 'l', '来', 200);"
|
||
"INSERT INTO tbl_1_l VALUES('liang', 'l', '两', 200);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, true, paths);
|
||
type(session, "haoyonga");
|
||
require(session.select_candidate(candidate_index(session, "好用啊")).commit == "好用啊",
|
||
"The literal reading of haoyonga must be selectable with autocorrection off.");
|
||
Database database(directory / "msime.db");
|
||
require(database.query_integer("SELECT weight FROM tbl_3_h WHERE value='好用啊'") > 100,
|
||
"Learning a pick with autocorrection off must update the literal key it was queried under.");
|
||
}
|
||
|
||
{
|
||
metasequoia::InputSession helpcode(SchemeType::Quanpin, 0u, true, true, true, paths);
|
||
helpcode.set_quanpin_helpcode_enabled(true);
|
||
require(helpcode.set_helpcode_schema("lantian"), "The helpcode fixture was not selected.");
|
||
type(helpcode, "haoyongaK");
|
||
require(find_candidate_index(helpcode, "好用啊") < helpcode.candidates().size(),
|
||
"With autocorrection off a helpcode must still offer the literal reading hao'yong'a.");
|
||
require(find_candidate_index(helpcode, "好用嘎") == helpcode.candidates().size(),
|
||
"With autocorrection off a helpcode must not query the alias re-cut hao'yong'ga.");
|
||
}
|
||
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, true, paths);
|
||
session.enable_fixed_positions();
|
||
type(session, "laing");
|
||
const auto index = candidate_index(session, "来");
|
||
const auto selected = session.candidates()[index];
|
||
require(session.set_candidate_position(index, 2).handled, "Pinning a literal candidate must succeed.");
|
||
const auto key = selected.canonical_pinyin.empty() ? selected.pinyin : selected.canonical_pinyin;
|
||
const auto journal = metasequoia::path_to_utf8(paths.user(metasequoia::assets::user_journal));
|
||
require(!user_dictionary::is_fixed(journal, "liang", key, selected.word),
|
||
"With autocorrection off, laing must not pin into the liang context.");
|
||
const auto literal = quanpin::cut_pinyin_by_mode("laing", "correction", false);
|
||
require(!literal.empty() &&
|
||
user_dictionary::is_fixed(journal, quanpin::join_segments(literal.front()), key, selected.word),
|
||
"With autocorrection off, laing must pin under its literal context.");
|
||
}
|
||
|
||
// 方案与词典按同一条规则读掩码:只带删除 / 插入位的掩码在词典侧按「关」处理,方案也不能
|
||
// 跑别名层,否则预编辑显示纠正、候选却按未纠正查。
|
||
{
|
||
QuanpinScheme scheme;
|
||
scheme.set_raw_input("sahng", "sahng");
|
||
scheme.set_autocorrect_types(quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion);
|
||
require(scheme.build_request().segmentation != "shang",
|
||
"A mask without the user-facing bits must not run the alias layer in the scheme.");
|
||
scheme.set_autocorrect_types(quanpin::kAutocorrectTransposition);
|
||
require(scheme.build_request().segmentation == "shang",
|
||
"The transposition switch must run the alias layer in the scheme.");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
|
||
// 合法输入的等音节数字面切分按整句打分争领衔(arbitrate_literal_segmentations)。
|
||
// yibananzhao 的最少音节规则平局时取较短首音节,落在 yi'ba'nan'zhao;fixture 只给
|
||
// yi'ban'an'zhao 一侧词组(一般、按照),另一侧只有单字。词库目录没有 sc.lm,词格退回
|
||
// 启发式打分(单字 ln(w/1e6)、词组 ln(w)+3×音节数),分差完全由权重决定,用例因此确定。
|
||
void run_literal_segmentation_arbitration_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
require(quanpin::enumerate_min_segmentations("yibananzhao", 8).size() == 2,
|
||
"Both four-syllable readings of yibananzhao must be enumerated.");
|
||
require(quanpin::enumerate_min_segmentations("nihao", 8).size() == 1,
|
||
"An unambiguous input must have exactly one minimal segmentation.");
|
||
|
||
const std::filesystem::path directory = data_directory / "literal-segmentation";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_y VALUES('yi''ban', 'yb', '一般', 1000);");
|
||
database.execute("CREATE TABLE tbl_2_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_a VALUES('an''zhao', 'az', '按照', 1000);");
|
||
database.execute("CREATE TABLE tbl_1_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_y VALUES('yi', 'y', '一', 1000);");
|
||
database.execute("CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_b VALUES('ba', 'b', '把', 1000);");
|
||
database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('nan', 'n', '难', 1000);");
|
||
database.execute("CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_z VALUES('zhao', 'z', '找', 1000);");
|
||
// fanganhao:fan'gan'hao(主切)与 fang'an'hao 只差 ln(1000/600)=0.5,主切领衔,
|
||
// 另一读法的整句在分差以内,占第 2 位。
|
||
database.execute("CREATE TABLE tbl_2_f(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_f VALUES('fan''gan', 'fg', '反感', 1000);"
|
||
"INSERT INTO tbl_2_f VALUES('fang''an', 'fa', '方案', 600);");
|
||
database.execute("CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_h VALUES('hao', 'h', '好', 1000);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
SentenceAssociationOptions lattice_only;
|
||
lattice_only.word_lattice = true;
|
||
const auto has_word = [](const metasequoia::InputSession &target, const std::string &word) {
|
||
const auto &items = target.candidates();
|
||
return std::any_of(items.begin(), items.end(), [&](const WordItem &item) { return item.word == word; });
|
||
};
|
||
|
||
// 另一种切分的整句分高出一截:它领衔,预编辑跟着它分;原切分的整句分差太远,不占第 2 位。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, true, paths);
|
||
session.set_sentence_association(lattice_only);
|
||
type(session, "yibananzhao");
|
||
require(!session.candidates().empty() && session.candidates().front().word == "一般按照",
|
||
"The better-scoring literal segmentation must lead.");
|
||
require(session.get_pinyin_segmentation_with_cases() == "yi'ban'an'zhao",
|
||
"The preedit must follow the leading segmentation.");
|
||
require(!has_word(session, "一把难找"), "A far-behind literal sentence must not be listed.");
|
||
(void)session.select_candidate(candidate_index(session, "一般"));
|
||
require(session.has_composition() && session.preedit() == "anzhao" &&
|
||
session.candidates().front().word == "按照",
|
||
"Selecting a prefix of the leading segmentation must leave the rest of it.");
|
||
}
|
||
// 整句来源全关:没有整句可比,与改造前一致,仍按最少音节切分。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, true, paths);
|
||
type(session, "yibananzhao");
|
||
require(session.get_pinyin_segmentation_with_cases() == "yi'ba'nan'zhao" && !has_word(session, "一般按照"),
|
||
"Without any sentence source the segmentation must not be re-arbitrated.");
|
||
}
|
||
// 主切领衔、另一读法分差以内:它的整句紧跟在第 2 位。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, true, paths);
|
||
session.set_sentence_association(lattice_only);
|
||
type(session, "fanganhao");
|
||
const auto &candidates = session.candidates();
|
||
require(candidates.size() >= 2 && candidates[0].word == "反感好" && candidates[1].word == "方案好",
|
||
"A close runner-up segmentation must take the second slot.");
|
||
require(session.get_pinyin_segmentation_with_cases() == "fan'gan'hao",
|
||
"The preedit must stay on the leading segmentation.");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
|
||
// 多字、漏字纠错的读音和原始字母不等长,选词时要按纠错切分记录的原始区间消耗字母,
|
||
// 不能按读音长度:buuhui 选「不会」曾剩下一个 i,选「张」会把 zhngg 一起吞掉。
|
||
void run_autocorrect_selection_consumption_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
const unsigned all_types = both | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
require(quanpin::corrected_reading_raw_length("buuhui", {"bu", "hui"}, all_types) == std::optional<size_t>(6),
|
||
"An insertion correction must cover every typed letter.");
|
||
// 多出的 u 归前归后两可(buu -> bu 或 uhui -> hui,代价相同),两种都对,只要剩下的
|
||
// 部分仍能纠回「会」;不能出现的是按读音长度把 hui 吞掉一截。
|
||
const auto prefix_length = quanpin::corrected_reading_raw_length("buuhui", {"bu"}, all_types);
|
||
require(prefix_length == std::optional<size_t>(2) || prefix_length == std::optional<size_t>(3),
|
||
"A corrected prefix must cover exactly the letters of one explanation.");
|
||
require(quanpin::corrected_reading_raw_length("zhngguo", {"zhang"}, all_types) == std::optional<size_t>(4),
|
||
"A deletion correction must cover only the letters actually typed.");
|
||
require(!quanpin::corrected_reading_raw_length("nihao", {"ni"}, all_types).has_value(),
|
||
"A plain prefix of a legal input must keep the reading-length consumption.");
|
||
// 贪心切分在 jin 上卡死、要回溯才切得开的合法输入:曾被判成手误,「再」按 zaij -> zai 吞掉 j。
|
||
require(quanpin::is_complete_pinyin_input("zaijinuta") && quanpin::looks_like_jianpin_spelling("zaijinut"),
|
||
"A legal input that needs backtracking must still count as legal.");
|
||
require(!quanpin::corrected_reading_raw_length("zaijinuta", {"zai"}, all_types).has_value(),
|
||
"A plain prefix of a backtracking-only legal input must keep the reading-length consumption.");
|
||
|
||
const std::filesystem::path directory = data_directory / "autocorrect-selection-consumption";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_b VALUES('bu''hui', 'bh', '不会', 1000);");
|
||
database.execute("CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_b VALUES('bu', 'b', '不', 900000);");
|
||
database.execute("CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_h VALUES('hui', 'h', '会', 900000);"
|
||
"INSERT INTO tbl_1_h VALUES('hao', 'h', '好', 900000);");
|
||
database.execute("CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_z VALUES('zhang', 'z', '张', 900000);"
|
||
"INSERT INTO tbl_1_z VALUES('zai', 'z', '再', 900000);");
|
||
database.execute("CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_j VALUES('ji', 'j', '激', 900000);");
|
||
database.execute("CREATE TABLE tbl_1_t(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_t VALUES('ta', 't', '他', 900000);");
|
||
database.execute("CREATE TABLE tbl_1_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_g VALUES('guo', 'g', '国', 900000);");
|
||
database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('ni', 'n', '你', 900000);"
|
||
"INSERT INTO tbl_1_n VALUES('nu', 'n', '怒', 900000);"
|
||
"INSERT INTO tbl_1_n VALUES('niu', 'n', '牛', 900000);");
|
||
database.execute("CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', '你好', 1000);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
|
||
// 多打一个 u:整词上屏后不能剩下 i。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "buuhui");
|
||
const auto committed = session.select_candidate(candidate_index(session, "不会"));
|
||
require(committed.commit == "不会" && !session.has_composition(),
|
||
"Selecting the insertion-corrected word must consume the whole input.");
|
||
}
|
||
// 只选前缀「不」:剩下的部分(hui 或 uhui)仍要能出「会」。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "buuhui");
|
||
(void)session.select_candidate(candidate_index(session, "不"));
|
||
require(session.has_composition() && (session.preedit() == "hui" || session.preedit() == "uhui") &&
|
||
find_candidate_index(session, "会") < session.candidates().size(),
|
||
"After a corrected prefix the rest must still read as hui.");
|
||
}
|
||
// 漏打一个 a:选「张」只消耗 zhng,剩下 guo。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "zhngguo");
|
||
(void)session.select_candidate(candidate_index(session, "张"));
|
||
require(session.has_composition() && session.preedit() == "guo",
|
||
"A deletion-corrected prefix must not swallow the following letters.");
|
||
}
|
||
// 合法输入照旧按读音长度消耗。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "nihao");
|
||
(void)session.select_candidate(candidate_index(session, "你"));
|
||
require(session.has_composition() && session.preedit() == "hao",
|
||
"A plain prefix of a legal input must keep its consumption.");
|
||
}
|
||
// 要回溯才切得开的合法输入不进纠错:预编辑按字面切分,选「再」剩 jinuta。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, all_types, true, true, true, paths);
|
||
type(session, "zaijinuta");
|
||
require(session.get_pinyin_segmentation_with_cases() == "zai'ji'nu'ta",
|
||
"A legal input must be displayed by its literal segmentation.");
|
||
(void)session.select_candidate(candidate_index(session, "再"));
|
||
require(session.has_composition() && session.preedit() == "jinuta",
|
||
"Selecting a literal prefix must not consume letters of the next syllable.");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
|
||
// 阶段 2 生成式纠错空间(任务 quanpin-autocorrect-generated-space):静态表
|
||
// 形状之外的单编辑手误由生成式索引兜底,权重落贵档(15)。本任务把生成式的
|
||
// 「任意字母插入」分片移除(远键插入不再是纠错形状),只保留远键替换。
|
||
// 隔离 fixture:
|
||
// - shatg = shang 的 n→t(t 非邻键,远键替换)
|
||
// - schang:远键插入(c 既非 s 的邻键也非 h 的邻键),本任务后不得再纠错
|
||
// (移除断言;不用 shxang——它同时是 shuang 的远键替换,会留下替换读法)
|
||
// - chng 同键双读:chng→chang(静态漏字 11)与 chng→cang(生成 15),
|
||
// 静态最优时生成切分整体丢弃——静态优先语义的判别性断言
|
||
// - shatngzh = head shatng + 简拼尾 zh:head 组合路径同享静态优先
|
||
void run_autocorrect_generated_space_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "autocorrect-generated";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('shang', 's', '上', 900);");
|
||
database.execute("CREATE TABLE tbl_1_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_c VALUES('chang', 'c', '长', 900);"
|
||
"INSERT INTO tbl_1_c VALUES('cang', 'c', '仓', 5000);"
|
||
"INSERT INTO tbl_1_c VALUES('chu', 'c', '出', 900);"
|
||
"INSERT INTO tbl_1_c VALUES('chua', 'c', '欻', 900);");
|
||
database.execute("CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_s VALUES('shang''zh', 'sz', '上周', 900);");
|
||
// huanzh/huansh/huanzg 的明文读法都是 huan + 简拼尾,生成式远键读法(head
|
||
// huanz/huans 经 z→g / s→g 成 huang)各放一个单字:明文给 欢,被拒的组合读法
|
||
// 若出现会带来 黄。
|
||
database.execute("CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_h VALUES('huan', 'h', '欢', 900);"
|
||
"INSERT INTO tbl_1_h VALUES('huang', 'h', '黄', 900);");
|
||
// 自洽明文负例的判别夹具:「明文 head 音节」与「被拒的邻键 head 音节」各一个字。
|
||
// 规则 3 生效时只有前者在场;若组合纠错发生,后者(xia/dia/...)会顶替它。
|
||
// jv/lve 系按词库正键写法登记(jv 归一为 ju、jve 归一为 jue)。
|
||
database.execute("CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_x VALUES('xi', 'x', '西', 900);"
|
||
"INSERT INTO tbl_1_x VALUES('xia', 'x', '下', 900);");
|
||
database.execute("CREATE TABLE tbl_1_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_d VALUES('di', 'd', '低', 900);"
|
||
"INSERT INTO tbl_1_d VALUES('dia', 'd', '嗲', 900);");
|
||
database.execute("CREATE TABLE tbl_1_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_g VALUES('gu', 'g', '古', 900);"
|
||
"INSERT INTO tbl_1_g VALUES('gua', 'g', '挂', 900);");
|
||
database.execute("CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_j VALUES('ji', 'j', '机', 900);"
|
||
"INSERT INTO tbl_1_j VALUES('jia', 'j', '家', 900);"
|
||
"INSERT INTO tbl_1_j VALUES('ju', 'j', '句', 900);"
|
||
"INSERT INTO tbl_1_j VALUES('jue', 'j', '决', 900);");
|
||
database.execute("CREATE TABLE tbl_1_q(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_q VALUES('qu', 'q', '去', 900);"
|
||
"INSERT INTO tbl_1_q VALUES('que', 'q', '却', 900);");
|
||
database.execute("CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_z VALUES('zhu', 'z', '主', 900);"
|
||
"INSERT INTO tbl_1_z VALUES('zhua', 'z', '抓', 900);");
|
||
database.execute("CREATE TABLE tbl_1_l(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_l VALUES('lv', 'l', '绿', 900);"
|
||
"INSERT INTO tbl_1_l VALUES('lve', 'l', '略', 900);");
|
||
database.execute("CREATE TABLE tbl_3_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_s VALUES('sha''tang''zh', 'stz', '沙汤扎', 900);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
const unsigned both = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
|
||
// 远键替换:shatg → shang(生成对,贵档 15)。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "shatg");
|
||
const auto found = find_candidate_index(session, "上");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "shatg",
|
||
"A far-substitution typo must be corrected to 上 with the typed letters recorded.");
|
||
}
|
||
|
||
// 远键插入(③)已随本任务移除:schang(c 既非 s 的邻键也非 h 的邻键,且不是
|
||
// 任何合法音节的远键替换)必须不再有纠错读法。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "schang");
|
||
require(quanpin::autocorrect_cut("schang", both).empty(),
|
||
"Far-key insertions were removed: schang must have no correction cut.");
|
||
require(std::none_of(session.candidates().begin(), session.candidates().end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }),
|
||
"Far-key insertions were removed: schang must produce no corrected candidate.");
|
||
}
|
||
|
||
// 静态优先语义:chng = chang 漏 a。读法竞争——chng→chang(静态漏字 11)与
|
||
// chng→cang(生成远键替换 h→a,15)。最优切分为纯静态时生成切分整体丢弃:
|
||
// 仓不出现(混表加权仲裁依赖训练权重,方向阶段 3),长照常领衔。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "chng");
|
||
require(candidate_index(session, "长") == 0, "The in-table cheaper deletion reading must lead for chng.");
|
||
require(
|
||
find_candidate_index(session, "仓") == session.candidates().size(),
|
||
"Static-priority semantics: the generated cang reading must not surface when a static cut tops the input.");
|
||
}
|
||
|
||
// head+简拼尾组合路径的静态优先:shatngzh 在整串 k-best 上无解(尾部 zh 非
|
||
// 完整音节),走 head 重试。head shatng 的读法是 sha+tng→tang(静态漏字 11)。
|
||
// 生成式插入已随本任务移除,shang 读法(旧生成插入 15)不再存在。静态读法的
|
||
// 沙汤扎(sha'tang'zh 精确键)在场,上周(shang+zh)不得出现。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(session, "shatngzh");
|
||
require(find_candidate_index(session, "沙汤扎") < session.candidates().size(),
|
||
"The head-composition path must still correct shatng to sha+tang.");
|
||
require(find_candidate_index(session, "上周") == session.candidates().size(),
|
||
"Static priority must hold on the head path: the generated shang+zh reading must not surface.");
|
||
}
|
||
|
||
// head+简拼尾组合路径与整串同掩码:邻键 head 照常组合。shabg 的读法是 b→n
|
||
// 邻键替换(静态表 13),接上简拼尾 z 得 shang'z,命中上周。改动前 head 掩码
|
||
// 剥掉邻键位、shabg 无解,这条读法整个不存在(只剩 'sha' 前缀扫出的上)。
|
||
// 掩码按产品可达值给(engine.cpp 会把漏字/多字位与两个旧开关捆绑):两个开关
|
||
// 都开的 0xf 组合,只开换位开关的 0xd 不组合。
|
||
{
|
||
const unsigned transposition_only_product =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
metasequoia::InputSession session(SchemeType::Quanpin, both | transposition_only_product, true, true, true,
|
||
paths);
|
||
type(session, "shabgz");
|
||
const auto composed = find_candidate_index(session, "上周");
|
||
require(composed == 0 && session.candidates()[composed].pinyin == "shang'z" &&
|
||
session.candidates()[composed].corrected_from == "shabgz",
|
||
(std::string("A neighbor head must compose with a jianpin tail: shabgz -> shang'z, preedit ") +
|
||
session.get_pinyin_segmentation_with_cases())
|
||
.c_str());
|
||
|
||
// 邻键位关掉则 head 无读法,组合不发生:只剩 'sha' 前缀扫出的上,不带纠错标记。
|
||
metasequoia::InputSession without_neighbor(SchemeType::Quanpin, transposition_only_product, true, true, true,
|
||
paths);
|
||
type(without_neighbor, "shabgz");
|
||
require(std::none_of(without_neighbor.candidates().begin(), without_neighbor.candidates().end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }),
|
||
"Without the neighbor bit the neighbor-headed input must not compose.");
|
||
}
|
||
|
||
// 生成式远键读法不上 head:huanzh/huansh/huanzg 的正确拼写都是「完整音节 huan +
|
||
// 简拼尾」,但邻居位里的生成式空间带 z→g / s→g 远键替换,经最短尾切分(tail "h"、
|
||
// 或 huanzg 的 "g")会把 head "huanz"/"huans" 读成 huang,组合出 huang'h 并顶掉明文
|
||
// 切分(代价档仲裁不了「未纠正读法」)。组合路径只接受纯静态 top cut——k-best 静态
|
||
// 优先 ⇒ front 带 has_generated 等价于「无静态读法解释 head」——故探到生成读法即跳过。
|
||
// 两个产品可达的全开掩码 0xe(只开邻键开关)/0xf(两个开关都开)都保持明文:欢是
|
||
// 明文读法的候选且无纠错标记,黄只可能来自被拒的组合读法,一个都不许带纠错标记。
|
||
{
|
||
const unsigned neighbor_product =
|
||
quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
for (const std::string spelled : {"huanzh", "huansh", "huanzg"})
|
||
{
|
||
for (const unsigned mask : {neighbor_product, both | neighbor_product})
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, true, paths);
|
||
type(session, spelled);
|
||
const auto plain = find_candidate_index(session, "欢");
|
||
require(plain < session.candidates().size() && session.candidates()[plain].corrected_from.empty() &&
|
||
session.candidates()[plain].pinyin.rfind("huan", 0) == 0,
|
||
(std::string("A correctly spelled head with a jianpin tail must stay plain (") + spelled +
|
||
"), preedit " + session.get_pinyin_segmentation_with_cases())
|
||
.c_str());
|
||
require(find_candidate_index(session, "黄") == session.candidates().size(),
|
||
(std::string("A generated far-key head reading must not rewrite a correctly spelled input (") +
|
||
spelled + ").")
|
||
.c_str());
|
||
require(std::none_of(session.candidates().begin(), session.candidates().end(),
|
||
[&](const WordItem &item) { return item.corrected_from == spelled; }),
|
||
(std::string("No candidate may carry a correction for a correctly spelled ") + spelled + ".")
|
||
.c_str());
|
||
}
|
||
}
|
||
}
|
||
|
||
// 自洽明文读法优先(规则 3):head 是完整合法音节、尾是合法简拼尾时,输入本身有
|
||
// 明文读法,组合纠错整个让位。判别用词库层切分——两态实测(改动前 = 临时还原规则 3
|
||
// 重建):0xe/0xf 下「完整音节 + sh/zh」被最短尾拆成 s/h、z/h 再经静态邻键凑成合法
|
||
// 音节(xish→xia'h、jvsh→jve'h 等),规则 3 后回到明文;0x0/0xd 两态逐位一致。会话
|
||
// 层再确认明文候选在场、被拒的邻键候选不在场(fixture 各放了一个字)。
|
||
{
|
||
const unsigned neighbor_product =
|
||
quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
struct SelfConsistentCase
|
||
{
|
||
const char *input;
|
||
const char *plain_segmentation;
|
||
const char *plain_word;
|
||
const char *rewritten_word;
|
||
};
|
||
for (const SelfConsistentCase &test :
|
||
{SelfConsistentCase{"xish", "xi'sh", "西", "下"}, SelfConsistentCase{"dish", "di'sh", "低", "嗲"},
|
||
SelfConsistentCase{"gush", "gu'sh", "古", "挂"}, SelfConsistentCase{"jish", "ji'sh", "机", "家"},
|
||
SelfConsistentCase{"qush", "qu'sh", "去", "却"}, SelfConsistentCase{"zhush", "zhu'sh", "主", "抓"},
|
||
SelfConsistentCase{"chush", "chu'sh", "出", "欻"}, SelfConsistentCase{"lvsh", "lv'sh", "绿", "略"},
|
||
SelfConsistentCase{"jvsh", "ju'sh", "句", "决"}})
|
||
{
|
||
for (const unsigned mask : {neighbor_product, both | neighbor_product})
|
||
{
|
||
QuanpinDictionary dictionary(metasequoia::path_to_utf8(directory / "msime.db"));
|
||
(void)dictionary.query(test.input, "", mask);
|
||
require(dictionary.get_pinyin_segmentation() == test.plain_segmentation,
|
||
(std::string("A self-consistent plain reading must survive the tail path (") + test.input +
|
||
" -> " + dictionary.get_pinyin_segmentation() + ").")
|
||
.c_str());
|
||
metasequoia::InputSession session(SchemeType::Quanpin, mask, true, true, true, paths);
|
||
type(session, test.input);
|
||
const auto plain = find_candidate_index(session, test.plain_word);
|
||
require(plain < session.candidates().size() && session.candidates()[plain].corrected_from.empty(),
|
||
(std::string("The plain head must stay an unmarked candidate (") + test.input + ").").c_str());
|
||
require(find_candidate_index(session, test.rewritten_word) == session.candidates().size(),
|
||
(std::string("A rewritten neighbour head must not surface (") + test.input + ").").c_str());
|
||
}
|
||
}
|
||
}
|
||
|
||
// 不变项、正例护栏与回归集(掩码取产品可达的 0x0/0xd/0xe/0xf;0xd = 换位+漏字+多字,
|
||
// 0xe = 邻键+漏字+多字):
|
||
// - shabgz:0xd(无邻键位,head shabg 无读法)不组合,0xe/0xf(含邻键位)组合出 shang'z ——
|
||
// 本任务的正面目标,必须仍成立(防规则 3 误杀)。
|
||
// - hauzh/hauz:结构性手误的组合不受规则 1/2/3 影响,0xd/0xf 仍是 hua'zh/hua'z;0xe
|
||
// (只开邻键开关、没有换位读法)下 head "hau" 走邻键 u→i 读成 hai,组合出 hai'zh/hai'z ——
|
||
// 规则 1 在 0xe 上的新增面(0xd 无邻键位、0xf 换位读法更便宜,都不走这条)。
|
||
// - huazh/guazh/bazh:「完整音节 + zh 尾」由规则 3 回到明文。改动前这三条在 0xd/0xe/0xf
|
||
// 上都是 hua'h/gua'h/ba'h(head "huaz" 这类被多字位读成 hua),与邻键位无关——所以
|
||
// 0xd 这一档也能判别规则 3,是自洽明文类里唯一在无邻键掩码下有信号的样本。
|
||
// - xibd/tamd/zhemd:「完整音节 + 两个简拼声母」,head(xib = xi + b)本身过简拼形状门,
|
||
// 组合让位。不挡的话 0xe/0xf 下最短尾 d 留下 head xib,静态邻键 b->n 把它读成 xin,
|
||
// 组合出 xin'd 顶掉明文(旧的邻键掩码挡的就是这一类)。
|
||
// - 到不了组合路径的整串输入(sahng/shng/keneng/nbn)四掩码与改动前逐位一致。
|
||
{
|
||
const unsigned transposition_product =
|
||
quanpin::kAutocorrectTransposition | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
const unsigned neighbor_product =
|
||
quanpin::kAutocorrectNeighbor | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
struct SegmentationCase
|
||
{
|
||
const char *input;
|
||
unsigned mask;
|
||
const char *segmentation;
|
||
};
|
||
const SegmentationCase cases[] = {
|
||
{"shabgz", transposition_product, "sha'b'g'z"},
|
||
{"shabgz", neighbor_product, "shang'z"},
|
||
{"shabgz", both | neighbor_product, "shang'z"},
|
||
{"hauzh", neighbor_product, "hai'zh"},
|
||
{"hauz", neighbor_product, "hai'z"},
|
||
{"hauzh", both | neighbor_product, "hua'zh"},
|
||
{"hauz", both | neighbor_product, "hua'z"},
|
||
{"hauzh", transposition_product, "hua'zh"},
|
||
{"hauz", transposition_product, "hua'z"},
|
||
{"huazh", both | neighbor_product, "hua'zh"},
|
||
{"huazh", transposition_product, "hua'zh"},
|
||
{"guazh", transposition_product, "gua'zh"},
|
||
{"bazh", transposition_product, "ba'zh"},
|
||
{"xibd", transposition_product, "xi'b'd"},
|
||
{"xibd", neighbor_product, "xi'b'd"},
|
||
{"xibd", both | neighbor_product, "xi'b'd"},
|
||
{"tamd", neighbor_product, "ta'm'd"},
|
||
{"tamd", both | neighbor_product, "ta'm'd"},
|
||
{"zhemd", neighbor_product, "zhe'm'd"},
|
||
{"zhemd", both | neighbor_product, "zhe'm'd"},
|
||
{"sahng", 0u, "sa'h'n'g"},
|
||
{"sahng", transposition_product, "shang"},
|
||
{"sahng", neighbor_product, "shang"},
|
||
{"sahng", both | neighbor_product, "shang"},
|
||
{"shng", 0u, "sh'n'g"},
|
||
{"shng", transposition_product, "shang"},
|
||
{"shng", neighbor_product, "shang"},
|
||
{"shng", both | neighbor_product, "shang"},
|
||
{"keneng", 0u, "ke'neng"},
|
||
{"keneng", transposition_product, "ke'neng"},
|
||
{"keneng", neighbor_product, "ke'neng"},
|
||
{"keneng", both | neighbor_product, "ke'neng"},
|
||
{"nbn", 0u, "n'b'n"},
|
||
{"nbn", transposition_product, "n'b'n"},
|
||
{"nbn", neighbor_product, "nan"},
|
||
{"nbn", both | neighbor_product, "nan"},
|
||
};
|
||
for (const SegmentationCase &test : cases)
|
||
{
|
||
QuanpinDictionary dictionary(metasequoia::path_to_utf8(directory / "msime.db"));
|
||
(void)dictionary.query(test.input, "", test.mask);
|
||
require(dictionary.get_pinyin_segmentation() == test.segmentation,
|
||
(std::string("Segmentation regression for ") + test.input + ": expected " + test.segmentation +
|
||
", got " + dictionary.get_pinyin_segmentation())
|
||
.c_str());
|
||
}
|
||
}
|
||
|
||
// 守卫与合法输入:尾位插入(shangv 类)受简拼尾守卫——纠错不触发由
|
||
// test_pinyin 的 zheg 用例与输入门不变式覆盖,此处不重复;合法拼写原样直出。
|
||
{
|
||
metasequoia::InputSession legal(SchemeType::Quanpin, both, true, true, true, paths);
|
||
type(legal, "shang");
|
||
require(candidate_index(legal, "上") == 0 && legal.candidates()[0].corrected_from.empty(),
|
||
"A legal spelling must stay uncorrected.");
|
||
}
|
||
}
|
||
|
||
// 纯简拼形输入(每个字母都是一个声母)如果词库里真有它的简拼整词,由字面整词领衔:
|
||
// nbn/nqn/bqng 分别命中词库 jp 列的 能不能/女青年/并且能够,纠错读法(难/帮)只排在
|
||
// 后面,仍可选、仍带纠错标记。预编辑按字面简拼切分,选前缀单字只消耗它自己的字母。
|
||
// 漏了元音的 bng 即使有简拼整词「不能够」,「帮」也不能丢。shng 含双字母切分 sh,
|
||
// 不是纯简拼,漏字纠错照旧。
|
||
void run_autocorrect_pure_jianpin_guard_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "autocorrect-pure-jianpin";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('shang','s','上',900);"
|
||
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('nan','n','难',900);"
|
||
"INSERT INTO tbl_1_n VALUES('neng','n','能',800);"
|
||
"CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_b VALUES('bang','b','帮',900);"
|
||
"CREATE TABLE tbl_3_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_b VALUES('bu''neng''gou','bng','不能够',2000);"
|
||
"CREATE TABLE tbl_3_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_3_n VALUES('neng''bu''neng','nbn','能不能',5000);"
|
||
"INSERT INTO tbl_3_n VALUES('nv''qing''nian','nqn','女青年',3000);"
|
||
"CREATE TABLE tbl_4_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_4_b VALUES('bing''qie''neng''gou','bqng','并且能够',4000);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
const unsigned all = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor |
|
||
quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
|
||
|
||
struct Case
|
||
{
|
||
const char *input;
|
||
const char *word;
|
||
const char *display;
|
||
const char *corrected;
|
||
};
|
||
for (const Case &test : {Case{"nbn", "能不能", "n'b'n", "难"}, Case{"nqn", "女青年", "n'q'n", "难"},
|
||
Case{"bqng", "并且能够", "b'q'n'g", "帮"}, Case{"bng", "不能够", "b'n'g", "帮"}})
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, all, true, true, true, paths);
|
||
type(session, test.input);
|
||
require(!session.candidates().empty() && session.candidates().front().word == test.word &&
|
||
session.candidates().front().corrected_from.empty(),
|
||
(std::string("A pure-jianpin whole word must lead, uncorrected: ") + test.input).c_str());
|
||
const auto corrected = find_candidate_index(session, test.corrected);
|
||
require(corrected < session.candidates().size() && session.candidates()[corrected].corrected_from == test.input,
|
||
(std::string("The corrected reading must stay behind the jianpin word: ") + test.input).c_str());
|
||
require(session.get_pinyin_segmentation_with_cases() == test.display,
|
||
(std::string("The preedit must follow the literal jianpin reading: ") + test.input + " -> " +
|
||
session.get_pinyin_segmentation_with_cases())
|
||
.c_str());
|
||
}
|
||
|
||
// 选纯简拼的前缀单字只消耗它自己的声母,后面的 bn 留着继续输入。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, all, true, true, true, paths);
|
||
type(session, "nbn");
|
||
const auto committed = session.select_candidate(candidate_index(session, "能"));
|
||
require(committed.commit == "能" && session.has_composition() && session.preedit() == "bn",
|
||
"Selecting a jianpin prefix character must consume only its own initial.");
|
||
}
|
||
|
||
// 含双字母切分的输入不是纯简拼,漏字纠错照旧命中「上」并记下敲入的字母。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, all, true, true, true, paths);
|
||
type(session, "shng");
|
||
const auto found = find_candidate_index(session, "上");
|
||
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "shng",
|
||
"A non-pure-jianpin typo (shng) must still be corrected with the typed letters recorded.");
|
||
}
|
||
|
||
// 纠错关时逐位不变:纯简拼输入仍由字面简拼读法领衔,且无纠错标记。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, true, paths);
|
||
type(session, "bqng");
|
||
require(!session.candidates().empty() && session.candidates().front().word == "并且能够" &&
|
||
session.candidates().front().corrected_from.empty(),
|
||
"With autocorrection off a pure-jianpin reading must still lead, uncorrected.");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
|
||
void run_caret_prefix_session_tests(const std::filesystem::path &data_directory)
|
||
{
|
||
const std::filesystem::path directory = data_directory / "caret-prefix";
|
||
std::filesystem::create_directories(directory);
|
||
{
|
||
Database database(directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_n VALUES('ni','n','你',100);"
|
||
"INSERT INTO tbl_1_n VALUES('ni','n','拟',90);");
|
||
database.execute("CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',200);"
|
||
"INSERT INTO tbl_2_n VALUES('ni''hao','nh','拟好',100);");
|
||
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_s VALUES('shi','sh','是',100);");
|
||
database.execute("CREATE TABLE tbl_1_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_j VALUES('jie','j','接',100);");
|
||
database.execute("CREATE TABLE tbl_1_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO tbl_1_y VALUES('ya','y','呀',100);"
|
||
"INSERT INTO tbl_1_y VALUES('ya','y','压',90);");
|
||
database.execute("CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);"
|
||
"INSERT INTO wubi86 VALUES('aaaa','工',100);"
|
||
"INSERT INTO wubi86 VALUES('aaaa','或',50);");
|
||
}
|
||
|
||
metasequoia::RuntimePaths paths;
|
||
paths.resources = directory;
|
||
paths.user_data = directory;
|
||
paths.cache = directory;
|
||
paths.dictionaries = directory;
|
||
|
||
const auto words_of = [](const metasequoia::InputSession &session) {
|
||
std::vector<std::string> words;
|
||
words.reserve(session.candidates().size());
|
||
for (const WordItem &item : session.candidates())
|
||
{
|
||
words.push_back(item.word);
|
||
}
|
||
return words;
|
||
};
|
||
const auto same_word_list = [](const metasequoia::InputSession &session, const std::vector<std::string> &expected) {
|
||
if (session.candidates().size() != expected.size())
|
||
{
|
||
return false;
|
||
}
|
||
return std::equal(session.candidates().begin(), session.candidates().end(), expected.begin(),
|
||
[](const WordItem &left, const std::string &right) { return left.word == right; });
|
||
};
|
||
|
||
// R2/R3/R7:音节内 caret 向下取整到最后一个完整单元边界,候选等于前缀的候选;
|
||
// caret 未设置或落在末尾时与现状整串解码零差异。
|
||
const std::string sentence = "ni'hao'shi'jie";
|
||
const auto quanpin_words = [&](const std::string &typed) {
|
||
metasequoia::InputSession other(SchemeType::Quanpin, 0, true, true, false, paths);
|
||
type(other, typed);
|
||
return words_of(other);
|
||
};
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
|
||
type(session, sentence);
|
||
require(session.has_composition() && session.prefix_end() == sentence.size() &&
|
||
session.pending_suffix().empty(),
|
||
"An unset caret must decode the whole string");
|
||
const auto full_words = words_of(session);
|
||
require(same_word_list(session, full_words),
|
||
"The unset-caret baseline diverged from a plain typed composition");
|
||
|
||
for (const std::size_t caret : {std::size_t(5), std::size_t(6)})
|
||
{
|
||
session.set_caret(caret);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 3, "An intra-syllable caret must floor to the last complete unit boundary");
|
||
require(session.pending_suffix() == "hao'shi'jie",
|
||
"The pending suffix must keep the raw spelling the decode did not consume");
|
||
require(candidate_index(session, "你") < session.candidates().size(),
|
||
"The 'ni' prefix lost its dictionary candidates");
|
||
require(same_word_list(session, quanpin_words("ni")),
|
||
"The floored prefix must decode exactly like the typed prefix spelling");
|
||
}
|
||
|
||
// 首单元内侧不是串首:向上取整到首单元末尾,与 caret 落在 ni 边界上解码相同。
|
||
for (const std::size_t caret : {std::size_t(1), std::size_t(2)})
|
||
{
|
||
session.set_caret(caret);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 3 && session.pending_suffix() == "hao'shi'jie",
|
||
"A caret inside the first unit must round up to the end of that unit");
|
||
require(same_word_list(session, quanpin_words("ni")),
|
||
"The rounded-up first unit must decode exactly like the typed spelling");
|
||
}
|
||
|
||
session.set_caret(7);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 7 && session.pending_suffix() == "shi'jie",
|
||
"A caret on the hao boundary must consume ni'hao");
|
||
require(candidate_index(session, "你好") < session.candidates().size(),
|
||
"The ni'hao prefix lost its phrase candidates");
|
||
require(same_word_list(session, quanpin_words("ni'hao")),
|
||
"The ni'hao prefix must decode exactly like a typed ni'hao");
|
||
const auto at_hao = words_of(session);
|
||
session.set_caret(9);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 7 && same_word_list(session, at_hao),
|
||
"A caret inside 'shi' must floor back to the hao boundary");
|
||
|
||
session.set_caret(13);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 11 && session.pending_suffix() == "jie",
|
||
"A caret inside 'jie' must floor to the shi boundary");
|
||
require(!session.candidates().empty() && same_word_list(session, quanpin_words("ni'hao'shi")),
|
||
"The ni'hao'shi prefix must decode exactly like the typed spelling");
|
||
|
||
// R4:量化后前缀为空 → 无候选,raw/preedit/caret 原样。
|
||
session.set_caret(0);
|
||
session.recompute_candidates();
|
||
require(session.candidates().empty(), "A caret before the first unit must offer no candidate");
|
||
require(session.prefix_end() == 0 && session.pending_suffix() == sentence,
|
||
"An empty prefix must leave the whole string pending");
|
||
require(session.editing_text() == sentence && session.caret_position() == 0 && session.preedit() == sentence,
|
||
"An empty prefix must not disturb the composition or the preedit");
|
||
|
||
// 越界 caret 被夹到串尾 → 退化为整串解码(R7)。
|
||
session.set_caret(sentence.size() + 10);
|
||
session.recompute_candidates();
|
||
require(session.caret_position() == sentence.size() && session.prefix_end() == sentence.size(),
|
||
"An out-of-range caret must clamp to the end");
|
||
require(same_word_list(session, full_words), "A caret clamped to the end must restore the full-string decode");
|
||
session.set_caret(std::nullopt);
|
||
session.recompute_candidates();
|
||
require(same_word_list(session, full_words), "Unsetting the caret must restore the full-string decode");
|
||
}
|
||
|
||
// 只有一个单元时首单元内侧向上取整就是整串:按整串解码而非空前缀,
|
||
// 候选窗不被 IsCaretPrefixEmpty 收起。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
|
||
type(session, "ya");
|
||
require(session.prefix_end() == 2 && !session.candidates().empty(),
|
||
"The unset 'ya' caret must decode the whole string");
|
||
const auto full_words = words_of(session);
|
||
session.set_caret(1);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 2 && session.pending_suffix().empty(),
|
||
"A caret inside the first unit must not be mistaken for the string head");
|
||
require(same_word_list(session, full_words) && same_word_list(session, quanpin_words("ya")),
|
||
"An intra-first-unit caret must decode exactly like the whole 'ya'");
|
||
}
|
||
|
||
// pending_suffix 保留原始大小写:大写字母从光标处插入后原样留在后缀里。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin, 0, true, true, false, paths);
|
||
type(session, "ni'hao");
|
||
session.handle_command(metasequoia::Command::MoveHome);
|
||
require(session.handle_character('H').handled, "The uppercase insert at the caret was rejected");
|
||
require(session.editing_text() == "Hni'hao" && session.caret_position() == 1,
|
||
"The uppercase insert lost its case or position");
|
||
session.set_caret(0);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 0 && session.pending_suffix() == "Hni'hao",
|
||
"The pending suffix must preserve the typed casing");
|
||
session.set_caret(std::nullopt);
|
||
session.recompute_candidates();
|
||
require(session.editing_text() == "Hni'hao", "Restoring the end caret altered the raw text");
|
||
}
|
||
|
||
// 无单元模型(五笔):segment_raw_boundaries 为空 → caret 移动不量化,候选零变化。
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Wubi, 0, true, true, false, paths);
|
||
type(session, "aaaa");
|
||
require(session.has_composition() && candidate_index(session, "工") < session.candidates().size(),
|
||
"The wubi fixture lost its candidates");
|
||
const auto native = words_of(session);
|
||
for (const std::size_t caret : {std::size_t(0), std::size_t(2)})
|
||
{
|
||
session.set_caret(caret);
|
||
session.recompute_candidates();
|
||
require(same_word_list(session, native), "A scheme without the unit model must not re-decode by caret");
|
||
require(session.prefix_end() == 4 && session.pending_suffix().empty(),
|
||
"Without a unit model the caret never shortens the decode");
|
||
}
|
||
}
|
||
|
||
// 双拼贪心配对(engine spec #187):nihkb; → {0,2,4,6}、nihcb; → {0,2,3,5,6},
|
||
// caret 落在段中间时同样 floor 到完整段边界;首单元内侧向上取整到首单元末尾。
|
||
const auto shuangpin_words = [&](const std::string &typed) {
|
||
metasequoia::InputSession other(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile(), paths);
|
||
type(other, typed);
|
||
return words_of(other);
|
||
};
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Shuangpin, GetMicrosoftShuangpinProfile(), paths);
|
||
type(session, "nihkb;");
|
||
require(candidate_index(session, "你好") < session.candidates().size(),
|
||
"The shuangpin baseline lost its phrase candidates");
|
||
require(session.prefix_end() == 6 && session.pending_suffix().empty(),
|
||
"The unset shuangpin caret must decode the whole string");
|
||
|
||
session.set_caret(1);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 2 && session.pending_suffix() == "hkb;" &&
|
||
same_word_list(session, shuangpin_words("ni")),
|
||
"A caret inside the first shuangpin unit must round up to the end of that unit");
|
||
|
||
session.set_caret(3);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 2 && session.pending_suffix() == "hkb;",
|
||
"A caret inside the hk unit must floor to the ni boundary");
|
||
require(same_word_list(session, shuangpin_words("ni")),
|
||
"The shuangpin 'ni' prefix must decode exactly like the typed spelling");
|
||
|
||
session.set_caret(5);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 4 && session.pending_suffix() == "b;",
|
||
"A caret inside the b; unit must floor to the hk boundary");
|
||
require(candidate_index(session, "你好") < session.candidates().size(),
|
||
"The shuangpin nihk prefix lost its phrase candidates");
|
||
require(same_word_list(session, shuangpin_words("nihk")),
|
||
"The shuangpin nihk prefix must decode exactly like the typed spelling");
|
||
|
||
session.set_caret(6);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 6 && !session.candidates().empty(),
|
||
"A caret on the final shuangpin boundary must restore the full decode");
|
||
|
||
session.handle_command(metasequoia::Command::Cancel);
|
||
type(session, "nihcb;");
|
||
session.set_caret(4);
|
||
session.recompute_candidates();
|
||
require(session.prefix_end() == 3 && session.pending_suffix() == "cb;",
|
||
"The greedy pairing boundary must floor the caret to where cb starts");
|
||
require(same_word_list(session, shuangpin_words("nih")),
|
||
"The shuangpin nih prefix must decode exactly like the typed spelling");
|
||
}
|
||
|
||
std::filesystem::remove_all(directory);
|
||
}
|
||
} // namespace
|
||
|
||
int run_test()
|
||
{
|
||
const auto unique_suffix = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count());
|
||
const std::filesystem::path data_directory =
|
||
std::filesystem::temp_directory_path() / std::filesystem::u8path("metasequoia-session-词库-" + unique_suffix);
|
||
metasequoia::test::ScopedDataDirectoryCleanup cleanup(data_directory);
|
||
std::filesystem::create_directories(data_directory);
|
||
set_data_directory(data_directory);
|
||
|
||
#ifndef METASEQUOIA_FREQUENCY_TESTS_ONLY
|
||
{
|
||
const std::filesystem::path helpcode_directory = data_directory / "helpcodes";
|
||
write_file(helpcode_directory / "helpcode.txt", "你=ab\n拟=cd\n好=ef\n");
|
||
write_file(helpcode_directory / "zrm_helpcode_big_unique.txt", "你=cb\n拟=ad\n好=ef\n");
|
||
write_file(helpcode_directory / "shouyou2_0_helpcode.txt", "你=ab\n拟=cd\n好=ef\n");
|
||
write_file(helpcode_directory / "shouyouplus_helpcode.txt", "你=ab\n拟=cd\n好=ef\n");
|
||
write_file(helpcode_directory / "xiaohe_helpcode.txt", "你=ab\n拟=cd\n好=ef\n");
|
||
|
||
Database database(data_directory / "msime.db");
|
||
database.execute("CREATE TABLE tbl_2_n(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', '你好', 200)");
|
||
database.execute("INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', '拟好', 100)");
|
||
database.execute("CREATE TABLE tbl_2_z(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_2_z VALUES('zhong''guo', 'zg', '中国', 200)");
|
||
database.execute("CREATE TABLE tbl_2_d(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_2_d VALUES('dong''gua', 'dg', '冬瓜', 200)");
|
||
database.execute("INSERT INTO tbl_2_d VALUES('dong''an', 'da', '东安', 200)");
|
||
database.execute("CREATE TABLE tbl_2_b(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_2_b VALUES('bu''hao', 'bh', '不好', 200)");
|
||
database.execute("INSERT INTO tbl_2_b VALUES('bu''hao', 'bh', '补好', 100)");
|
||
database.execute("INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', '𠀀方案𠮷', 90)");
|
||
database.execute("INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', 'C语言 2', 80)");
|
||
database.execute("INSERT INTO tbl_2_n VALUES('ni''hao', 'nh', 'GitHub', 70)");
|
||
database.execute("CREATE TABLE tbl_1_j(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_1_j VALUES('ju', 'j', '居', 100)");
|
||
database.execute("CREATE TABLE tbl_1_q(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_1_q VALUES('qu', 'q', '去', 100)");
|
||
database.execute("CREATE TABLE tbl_1_x(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_1_x VALUES('xu', 'x', '需', 100)");
|
||
database.execute("CREATE TABLE tbl_1_y(key TEXT, jp TEXT, value TEXT, weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_1_y VALUES('yu', 'y', '与', 100)");
|
||
|
||
database.execute("INSERT INTO tbl_1_x VALUES('xi', 'x', '西', 100)");
|
||
database.execute("CREATE TABLE tbl_2_t(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_2_t VALUES('te''le','tl','特乐',100)");
|
||
database.execute("CREATE TABLE tbl_3_x(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
{
|
||
metasequoia::InputSession portable(SchemeType::Quanpin, true, false);
|
||
type(portable, "xi'te'le");
|
||
const auto first = portable.select_candidate(candidate_index(portable, "西"));
|
||
require(first.commit == "西" && portable.has_composition() && portable.preedit() == "te'le",
|
||
"Portable selection discarded the unconsumed pinyin suffix.");
|
||
const auto last = portable.select_candidate(candidate_index(portable, "特乐"));
|
||
require(last.commit == "特乐" && !portable.has_composition(),
|
||
"Portable continuation duplicated the committed prefix or retained completed input.");
|
||
require(database.query_integer("SELECT COUNT(*) FROM tbl_3_x WHERE key='xi''te''le' AND value='西特乐'") ==
|
||
1,
|
||
"Portable composition did not learn the canonical phrase across selections.");
|
||
type(portable, "xi'te'le");
|
||
(void)portable.select_candidate(candidate_index(portable, "西"));
|
||
require(portable.handle_command(metasequoia::Command::Cancel).handled && !portable.has_composition(),
|
||
"Cancel did not clear an incomplete portable phrase.");
|
||
|
||
type(portable, "xi'te'le");
|
||
(void)portable.select_candidate(candidate_index(portable, "西"));
|
||
while (portable.has_composition())
|
||
{
|
||
require(portable.handle_command(metasequoia::Command::Backspace).handled,
|
||
"Backspace did not consume the abandoned portable composition.");
|
||
}
|
||
type(portable, "nihao");
|
||
require(portable.select_candidate(candidate_index(portable, "你好")).commit == "你好",
|
||
"The composition following an abandoned phrase could not be committed.");
|
||
require(database.query_integer("SELECT COUNT(*) FROM tbl_3_x WHERE value='西你好'") == 0,
|
||
"A phrase abandoned by Backspace was learned together with the next composition.");
|
||
}
|
||
{
|
||
metasequoia::InputSession unlearned(SchemeType::Quanpin, true, false, true, false);
|
||
type(unlearned, "xi'te'le");
|
||
(void)unlearned.select_candidate(candidate_index(unlearned, "西"));
|
||
const auto punctuation = unlearned.handle_punctuation(',');
|
||
require(punctuation.commit == "特乐," && !unlearned.has_composition(),
|
||
"Punctuation failed to finish the remaining portable composition atomically.");
|
||
}
|
||
|
||
// 整句候选(词格 / Google 解码器)在词库里没有对应的行,调频无处落笔:用户
|
||
// 选中一条整句多少次,它下次仍然要靠猜,排序也跟着重算。选中即落成用户词组,
|
||
// 之后同样的输入就由词库那一行来回答。
|
||
database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_1_n VALUES('na','n','那',100)");
|
||
database.execute("INSERT INTO tbl_1_y VALUES('yi','y','一',100)");
|
||
database.execute("CREATE TABLE tbl_1_t(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
database.execute("INSERT INTO tbl_1_t VALUES('tiao','t','条',100)");
|
||
database.execute("CREATE TABLE tbl_3_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
{
|
||
metasequoia::InputSession sentence(SchemeType::Quanpin);
|
||
// 整句联想默认全关,这一段考的就是整句候选,得先把造句那两条打开。
|
||
SentenceAssociationOptions association;
|
||
association.word_lattice = true;
|
||
association.google = true;
|
||
sentence.set_sentence_association(association);
|
||
type(sentence, "na'yi'tiao");
|
||
const auto guessed = candidate_index(sentence, "那一条");
|
||
const auto guessed_source = sentence.candidates()[guessed].source;
|
||
require(guessed_source == CandidateSource::Generated || guessed_source == CandidateSource::Fallback,
|
||
"The sentence under test must be a guess, not a dictionary row.");
|
||
require(sentence.select_candidate(guessed).commit == "那一条" && !sentence.has_composition(),
|
||
"Selecting a whole-sentence candidate did not commit it.");
|
||
require(
|
||
database.query_integer("SELECT COUNT(*) FROM tbl_3_n WHERE key='na''yi''tiao' AND value='那一条'") == 1,
|
||
"A selected whole-sentence candidate was not learned as a user phrase.");
|
||
|
||
type(sentence, "na'yi'tiao");
|
||
const auto learned = candidate_index(sentence, "那一条");
|
||
require(sentence.candidates()[learned].source != CandidateSource::Generated &&
|
||
sentence.candidates()[learned].source != CandidateSource::Fallback,
|
||
"The learned sentence did not come back as a dictionary row.");
|
||
require(learned == 0, "The learned sentence did not outrank the guessed ones.");
|
||
(void)sentence.select_candidate(learned);
|
||
}
|
||
{
|
||
// 学习整句是造词,不是调频:关掉候选学习的会话一条也不该落库。
|
||
metasequoia::InputSession unlearned(SchemeType::Quanpin, 0, true, true, false);
|
||
SentenceAssociationOptions association;
|
||
association.word_lattice = true;
|
||
association.google = true;
|
||
unlearned.set_sentence_association(association);
|
||
type(unlearned, "na'yi'na");
|
||
const auto guessed = candidate_index(unlearned, "那一那");
|
||
(void)unlearned.select_candidate(guessed);
|
||
require(database.query_integer("SELECT COUNT(*) FROM tbl_3_n WHERE value='那一那'") == 0,
|
||
"A session with candidate learning disabled still stored a whole-sentence candidate.");
|
||
}
|
||
|
||
// A seven-syllable key and eight/nine-syllable keys cross the shipping
|
||
// table boundary. Creation, normal query and upgrade replay must agree.
|
||
database.execute("CREATE TABLE tbl_7_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
database.execute("CREATE TABLE tbl_others_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
const auto replay_path = data_directory / "replay.db";
|
||
const auto journal_path = metasequoia::path_to_utf8(data_directory / "format-journal.db");
|
||
Database replay_database(replay_path);
|
||
replay_database.execute("CREATE TABLE tbl_7_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
replay_database.execute("CREATE TABLE tbl_others_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)");
|
||
for (const int count : {7, 8, 9})
|
||
{
|
||
std::string key, word;
|
||
for (int i = 0; i < count; ++i)
|
||
{
|
||
if (i)
|
||
key += "'";
|
||
key += "ni";
|
||
word += "你";
|
||
}
|
||
const std::string expected_table = count == 7 ? "tbl_7_n" : "tbl_others_n";
|
||
require(quanpin::build_table_name(std::vector<std::string>(count, "ni")) == expected_table,
|
||
"Runtime lookup selected the wrong long-phrase table.");
|
||
metasequoia::InputSession writer(SchemeType::Quanpin);
|
||
require(writer.store_user_phrase_from_canonical_pinyin(key, word) == 0,
|
||
"Canonical phrase creation could not write the public format.");
|
||
type(writer, key);
|
||
(void)candidate_index(writer, word);
|
||
require(user_dictionary::record_user_insert(journal_path, user_dictionary::DictionaryKind::Pinyin, key,
|
||
word, 10000),
|
||
"The long-phrase journal operation failed.");
|
||
}
|
||
const auto replay = user_dictionary::replay(journal_path, metasequoia::path_to_utf8(replay_path),
|
||
metasequoia::path_to_utf8(data_directory / "replay-english.db"));
|
||
require(replay.failed == 0 && replay.applied == 3 &&
|
||
replay_database.query_integer("SELECT COUNT(*) FROM tbl_7_n") == 1 &&
|
||
replay_database.query_integer("SELECT COUNT(*) FROM tbl_others_n") == 2,
|
||
"Upgrade replay disagreed with the public long-phrase table format.");
|
||
|
||
// Hosts that insert text asynchronously use the same composition owner as
|
||
// portable character/command clients. Exercise their sequence boundary here.
|
||
for (const bool raw_dispatch : {false, true})
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin);
|
||
const std::string input = "xi'te'le";
|
||
if (raw_dispatch)
|
||
{
|
||
session.set_pinyin_sequence(input);
|
||
session.set_pinyin_sequence_with_cases(input);
|
||
session.recompute_candidates();
|
||
}
|
||
else
|
||
{
|
||
type(session, input);
|
||
}
|
||
const auto query_before_selection = session.online_query();
|
||
const auto first = session.advance_composition_after_selection("xi", "西", "xi");
|
||
require(first.continues_composition && session.get_pinyin_sequence() == "te'le",
|
||
"Partial selection lost the remaining manually delimited input.");
|
||
require(first.consumed_raw_input_with_cases == "xi",
|
||
"Partial selection did not report the raw input it consumed.");
|
||
const auto progress = session.update_creating_word_progress("", "", "西", first);
|
||
require(!progress.completed && progress.pinyin == "xi" && progress.preedit == "西te'le",
|
||
"Partial selection produced the wrong phrase progress.");
|
||
require(query_before_selection && !session.apply_online_candidate(*query_before_selection, "旧",
|
||
CandidateSource::CloudSuggestion),
|
||
"Partial selection accepted a result for the previous composition.");
|
||
const auto modern_query = session.online_query();
|
||
const auto host_query = session.get_cloud_query_state();
|
||
require(modern_query && modern_query->query_text == host_query.query_text &&
|
||
modern_query->cache_key == host_query.cache_key,
|
||
"Portable and asynchronous hosts produced different online queries.");
|
||
const auto last = session.advance_composition_after_selection("te'le", "特乐", "te'le");
|
||
require(!last.continues_composition && last.consumed_raw_input_with_cases == "te'le",
|
||
"The final selection did not report the raw input it consumed.");
|
||
const auto complete = session.update_creating_word_progress(progress.pinyin, progress.word, "特乐", last);
|
||
require(complete.completed && complete.can_store && complete.pinyin == "xi'te'le" &&
|
||
complete.word == "西特乐",
|
||
"The completed phrase did not retain canonical pinyin across selections.");
|
||
const auto invalid = session.update_creating_word_progress("", "西", "特乐", last);
|
||
require(invalid.completed && !invalid.can_store && invalid.pinyin.empty(),
|
||
"A phrase with an earlier unknown canonical reading became storeable.");
|
||
session.set_pinyin_sequence("pending");
|
||
session.reset_state();
|
||
session.recompute_candidates();
|
||
require(!session.has_composition(), "Reset left a pending host composition alive.");
|
||
}
|
||
|
||
// A host that retracts a selected segment replays the reported spelling,
|
||
// so it must keep the casing the user typed rather than reuse the
|
||
// normalized pre-selection raw.
|
||
{
|
||
metasequoia::InputSession session(SchemeType::Quanpin);
|
||
session.set_pinyin_sequence("xi'te'le");
|
||
session.set_pinyin_sequence_with_cases("Xi'Te'Le");
|
||
session.recompute_candidates();
|
||
const auto transition = session.advance_composition_after_selection("xi", "西", "xi");
|
||
require(transition.continues_composition && transition.consumed_raw_input_with_cases == "Xi",
|
||
"Consumed input lost the user's original casing.");
|
||
// Retracting that selection replays the reported spelling. The
|
||
// restored raw must survive the host-side round trip unchanged.
|
||
session.set_pinyin_sequence(transition.consumed_raw_input_with_cases);
|
||
session.set_pinyin_sequence_with_cases(transition.consumed_raw_input_with_cases);
|
||
session.recompute_candidates();
|
||
require(session.get_pinyin_sequence_with_cases() == "Xi" && session.get_pinyin_sequence() == "xi",
|
||
"Restoring the consumed spelling did not round-trip through the session.");
|
||
}
|
||
|
||
metasequoia::InputSession default_session;
|
||
require(default_session.scheme_type() == SchemeType::Quanpin,
|
||
"The default input scheme should be full pinyin.");
|
||
require(default_session.quanpin_autocorrect_types() == 0,
|
||
"Pinyin autocorrection should default to off (no type bits set).");
|
||
require(default_session.helpcode_enabled(), "Helpcode should be enabled by default.");
|
||
require(default_session.chinese_punctuation_enabled(), "Chinese punctuation should be enabled by default.");
|
||
require(default_session.candidate_learning_enabled(), "Candidate learning should be enabled by default.");
|
||
|
||
struct UmlautAliasCase
|
||
{
|
||
const char *pinyin;
|
||
const char *candidate;
|
||
};
|
||
const std::array<UmlautAliasCase, 4> umlaut_alias_cases = {
|
||
{{"jv", "居"}, {"qv", "去"}, {"xv", "需"}, {"yv", "与"}}};
|
||
for (const auto &test_case : umlaut_alias_cases)
|
||
{
|
||
metasequoia::InputSession alias_session;
|
||
type(alias_session, test_case.pinyin);
|
||
require(candidate_index(alias_session, test_case.candidate) == 0,
|
||
"A v-form umlaut syllable did not query its canonical dictionary key.");
|
||
}
|
||
|
||
// The alias layer that folds a dropped final g (zhonguo -> zhong'guo,
|
||
// dongua -> dong'gua) is part of autocorrection and now follows its
|
||
// switch, so these cases run with the switch explicitly on. The default
|
||
// session has it off -- the mask=0 block right below pins that.
|
||
const unsigned alias_mask = quanpin::kAutocorrectTransposition;
|
||
const std::array<UmlautAliasCase, 6> missing_final_g_cases = {{{"zhonguo", "中国"},
|
||
{"zhon'guo", "中国"},
|
||
{"zhongguo", "中国"},
|
||
{"dongua", "冬瓜"},
|
||
{"donggua", "冬瓜"},
|
||
{"dongan", "东安"}}};
|
||
for (const auto &test_case : missing_final_g_cases)
|
||
{
|
||
metasequoia::InputSession corrected(SchemeType::Quanpin, alias_mask);
|
||
type(corrected, test_case.pinyin);
|
||
require(candidate_index(corrected, test_case.candidate) == 0,
|
||
"A missing final g did not resolve to the complete dictionary phrase.");
|
||
require(corrected.select_candidate(0).commit == test_case.candidate && !corrected.has_composition(),
|
||
"Selecting a corrected phrase left an unconsumed input suffix.");
|
||
}
|
||
|
||
// R2: with both switches off the engine must not correct at all. These
|
||
// inputs used to be rewritten by the always-on alias layer (sahng ->
|
||
// shang, zhonguo -> zhong'guo, laing -> liang, ahng -> hang, mihng ->
|
||
// ming); now the alias layer and the correction BFS both follow the
|
||
// mask, so nothing may come back marked as corrected.
|
||
for (const char *typo : {"sahng", "zhonguo", "laing", "ahng", "mihng"})
|
||
{
|
||
metasequoia::InputSession off_session(SchemeType::Quanpin, 0u);
|
||
type(off_session, typo);
|
||
require(std::none_of(off_session.candidates().begin(), off_session.candidates().end(),
|
||
[](const WordItem &item) { return !item.corrected_from.empty(); }),
|
||
"With autocorrection off no candidate may be marked as corrected.");
|
||
}
|
||
|
||
// R2 exception: the non-typo ü normalization must still work with the
|
||
// switches off. The run_test fixture carries ju/qu/xu/yu (nue/lve live in
|
||
// run_umlaut_alias_session_tests, which also pins the mask=0 path).
|
||
{
|
||
metasequoia::InputSession umlaut_session(SchemeType::Quanpin, 0u);
|
||
type(umlaut_session, "jv");
|
||
require(candidate_index(umlaut_session, "居") == 0,
|
||
"The ü normalization (jv -> ju) must stay active with autocorrection off.");
|
||
}
|
||
|
||
// PRD R5 regression: a fully legal spelling whose alias reading won the
|
||
// query (haoyonga -> hao'yong'ga) must still preedit the typed letters.
|
||
// The rewritten segmentation used to leak into the preedit verbatim,
|
||
// adding a phantom letter and shifting the TSF caret (haoyongg|a). The
|
||
// typed letters keep separators from their own legal reading, so one
|
||
// more letter does not drop the separators already shown.
|
||
struct PreeditCase
|
||
{
|
||
const char *pinyin;
|
||
const char *expected_preedit;
|
||
};
|
||
const std::array<PreeditCase, 3> legal_spelling_preedit_cases = {
|
||
{{"haoyong", "hao'yong"}, {"haoyonga", "hao'yong'a"}, {"dongan", "dong'an"}}};
|
||
for (const auto &test_case : legal_spelling_preedit_cases)
|
||
{
|
||
metasequoia::InputSession session;
|
||
type(session, test_case.pinyin);
|
||
require(session.get_pinyin_segmentation_with_cases() == test_case.expected_preedit,
|
||
"A legal spelling with an alias reading must preedit the typed letters.");
|
||
}
|
||
|
||
// Inputs the guard does not cover: with autocorrection off the alias
|
||
// layer no longer rewrites the letters, so these preedit their literal
|
||
// (greedy) cut -- the typed letters with a separator at the greedy cut
|
||
// position, and never a phantom letter. (With correction on the same
|
||
// inputs fall back to the typed spelling; that path is covered by the
|
||
// mask-enabled display cases in test_pinyin.cpp.)
|
||
const std::array<PreeditCase, 4> typed_fallback_preedit_cases = {{{"dongua", "don'gua"},
|
||
{"zhonguo", "zhon'guo"},
|
||
{"haoyonga'", "hao'yong'a'"},
|
||
{"hao'yonga", "hao'yong'a"}}};
|
||
for (const auto &test_case : typed_fallback_preedit_cases)
|
||
{
|
||
metasequoia::InputSession session;
|
||
type(session, test_case.pinyin);
|
||
require(session.get_pinyin_segmentation_with_cases() == test_case.expected_preedit,
|
||
"An uncorrected input must preedit its literal cut.");
|
||
}
|
||
|
||
metasequoia::InputSession no_autocorrect_session(SchemeType::Quanpin, 0u);
|
||
require(no_autocorrect_session.quanpin_autocorrect_types() == 0,
|
||
"The requested pinyin autocorrect setting was not retained.");
|
||
const unsigned both_types = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor;
|
||
no_autocorrect_session.set_quanpin_autocorrect_types(both_types);
|
||
require(no_autocorrect_session.quanpin_autocorrect_types() == both_types,
|
||
"The requested pinyin autocorrect mask was not retained.");
|
||
metasequoia::InputSession no_helpcode_session(SchemeType::Quanpin, true, false);
|
||
require(!no_helpcode_session.helpcode_enabled(), "The requested helpcode setting was not retained.");
|
||
type(no_helpcode_session, "ni");
|
||
require(!no_helpcode_session.handle_character('H').handled && no_helpcode_session.preedit() == "ni",
|
||
"A constructor-disabled Quanpin helpcode key was swallowed.");
|
||
|
||
metasequoia::InputSession shuangpin_session(SchemeType::Shuangpin);
|
||
require(shuangpin_session.scheme_type() == SchemeType::Shuangpin,
|
||
"The requested double-pinyin scheme was not retained.");
|
||
require(shuangpin_session.handle_character('n').handled && shuangpin_session.preedit() == "n",
|
||
"A valid Shuangpin letter was rejected.");
|
||
metasequoia::InputSession japanese_session(SchemeType::JapaneseRomaji);
|
||
require(japanese_session.handle_character('k').handled && japanese_session.preedit() == "k",
|
||
"A valid Japanese romaji letter was rejected.");
|
||
metasequoia::InputSession wubi_session(SchemeType::Wubi);
|
||
require(!wubi_session.handle_character('z').handled && wubi_session.preedit().empty(),
|
||
"An unsupported Wubi letter was swallowed.");
|
||
type(wubi_session, "abcd");
|
||
require(!wubi_session.handle_character('e').handled && wubi_session.preedit() == "abcd",
|
||
"A Wubi letter beyond the four-code limit was swallowed.");
|
||
require(!wubi_session.handle_character('\'').handled && wubi_session.preedit() == "abcd",
|
||
"An unsupported Wubi apostrophe was swallowed.");
|
||
|
||
metasequoia::InputSession ascii_punctuation_session(SchemeType::Quanpin, true, true, false);
|
||
require(!ascii_punctuation_session.chinese_punctuation_enabled(),
|
||
"The requested punctuation setting was not retained.");
|
||
require(!ascii_punctuation_session.handle_punctuation('.').handled,
|
||
"Disabled Chinese punctuation swallowed ASCII punctuation.");
|
||
|
||
metasequoia::InputSession no_learning_session(SchemeType::Quanpin, true, true, true, false);
|
||
require(!no_learning_session.candidate_learning_enabled(),
|
||
"The requested candidate-learning setting was not retained.");
|
||
type(no_learning_session, "buhao");
|
||
require(no_learning_session.candidates().size() >= 2 && no_learning_session.candidates().front().word == "不好",
|
||
"The learning test dictionary did not preserve its initial order.");
|
||
const auto unlearned_selection = no_learning_session.select_candidate(1);
|
||
require(unlearned_selection.handled && unlearned_selection.commit == "补好",
|
||
"Candidate selection failed while learning was disabled.");
|
||
metasequoia::InputSession verify_unlearned_session;
|
||
type(verify_unlearned_session, "buhao");
|
||
require(!verify_unlearned_session.candidates().empty() &&
|
||
verify_unlearned_session.candidates().front().word == "不好",
|
||
"A selected candidate was learned while candidate learning was disabled.");
|
||
|
||
metasequoia::InputSession uppercase_session;
|
||
require(!uppercase_session.handle_character('N').handled,
|
||
"An uppercase letter was swallowed while no composition was active.");
|
||
type(uppercase_session, "ni");
|
||
require(uppercase_session.handle_character('H').handled && uppercase_session.preedit() == "niH",
|
||
"An uppercase helpcode was rejected from an active pinyin composition.");
|
||
uppercase_session.handle_command(metasequoia::Command::Cancel);
|
||
|
||
metasequoia::InputSession duplicate_apostrophe_session;
|
||
type(duplicate_apostrophe_session, "ni");
|
||
require(duplicate_apostrophe_session.handle_character('\'').handled,
|
||
"The first Pinyin apostrophe was rejected.");
|
||
require(!duplicate_apostrophe_session.handle_character('\'').handled &&
|
||
duplicate_apostrophe_session.preedit() == "ni'",
|
||
"A duplicate Pinyin apostrophe was swallowed.");
|
||
|
||
metasequoia::InputSession session(SchemeType::Quanpin, true, true, true, false);
|
||
type(session, "nihao");
|
||
require(session.preedit() == "nihao", "The preedit did not mirror the raw pinyin.");
|
||
require(session.raw_segmentation() == "ni'hao" && session.normalized_segmentation() == "ni'hao",
|
||
"Quanpin segmentation was not exposed through the native session API.");
|
||
require(session.has_composition(), "Typing pinyin did not start a composition.");
|
||
require(session.candidates().size() >= 2, "The engine did not return both dictionary candidates.");
|
||
|
||
const auto selected = session.select_candidate(static_cast<std::size_t>(1));
|
||
require(selected.handled && selected.commit == "拟好", "Selecting the second candidate committed wrong text.");
|
||
require(!session.has_composition(), "Selecting a candidate did not end the composition.");
|
||
|
||
type(session, "nihao");
|
||
const auto by_word = session.select_candidate(std::string("拟好"));
|
||
require(by_word.handled && by_word.commit == "拟好", "Selecting a candidate by word committed the wrong text.");
|
||
|
||
type(session, "nihao");
|
||
const auto out_of_range = session.select_candidate(session.candidates().size());
|
||
require(!out_of_range.handled, "An out-of-range candidate index was accepted.");
|
||
const auto unknown_word = session.select_candidate(std::string("没有这个词"));
|
||
require(!unknown_word.handled, "An unknown candidate word was accepted.");
|
||
|
||
const auto leading = session.handle_command(metasequoia::Command::CommitCandidate);
|
||
require(leading.handled && leading.commit == "你好", "CommitCandidate did not commit the leading candidate.");
|
||
|
||
type(session, "nihao");
|
||
const auto composed_punctuation = session.handle_punctuation(',');
|
||
require(composed_punctuation.handled && composed_punctuation.commit == "你好,",
|
||
"Punctuation did not commit the candidate atomically.");
|
||
require(session.handle_punctuation('.').commit == "。", "Idle Chinese punctuation was not converted.");
|
||
require(session.handle_punctuation('"').commit == "“" && session.handle_punctuation('"').commit == "”",
|
||
"Double quotes did not alternate between opening and closing Chinese quotes.");
|
||
require(session.handle_punctuation('\'').commit == "‘" && session.handle_punctuation('\'').commit == "’",
|
||
"Single quotes did not alternate between opening and closing Chinese quotes.");
|
||
require(session.handle_punctuation('(').commit == "(" && session.handle_punctuation(')').commit == ")",
|
||
"Parentheses were not converted to Chinese punctuation.");
|
||
require(session.handle_punctuation('[').commit == "【" && session.handle_punctuation(']').commit == "】",
|
||
"Square brackets were not converted to Chinese punctuation.");
|
||
require(session.handle_punctuation('`').commit == "·" && session.handle_punctuation('$').commit == "¥" &&
|
||
session.handle_punctuation('^').commit == "……" && session.handle_punctuation('_').commit == "——",
|
||
"The keys whose Chinese form differs from ASCII were not converted.");
|
||
|
||
// The outer pair is 《》 and anything inside it uses 〈〉, so depth decides the mark.
|
||
require(session.handle_punctuation('<').commit == "《" && session.handle_punctuation('<').commit == "〈" &&
|
||
session.handle_punctuation('>').commit == "〉" && session.handle_punctuation('>').commit == "》",
|
||
"Book title marks did not nest.");
|
||
// An unmatched '>' must not drive the depth below zero, or the next '<' would open with 〈.
|
||
require(session.handle_punctuation('>').commit == "》", "An unmatched closing book title mark was not 》.");
|
||
require(session.handle_punctuation('<').commit == "《",
|
||
"An unmatched closing book title mark left the nesting depth negative.");
|
||
require(session.handle_punctuation('>').commit == "》", "Book title nesting did not return to depth zero.");
|
||
require(session.handle_punctuation('<').commit == "《" && session.handle_punctuation('>').commit == "》",
|
||
"Book-title brackets were not converted to Chinese punctuation.");
|
||
require(session.handle_punctuation('\\').commit == "、", "The enumeration comma was not converted.");
|
||
|
||
type(session, "nihao");
|
||
const auto digit = session.handle_candidate_key('2');
|
||
require(digit.handled && digit.commit == "拟好", "The 2 key did not commit the second candidate.");
|
||
|
||
metasequoia::InputSession learning_source_session;
|
||
type(learning_source_session, "nihao");
|
||
const auto learned_selection = learning_source_session.select_candidate(1);
|
||
require(learned_selection.handled && learned_selection.commit == "拟好",
|
||
"The learning source candidate was not selected.");
|
||
metasequoia::InputSession learned_session;
|
||
type(learned_session, "nihao");
|
||
require(!learned_session.candidates().empty() && learned_session.candidates().front().word == "拟好",
|
||
"Selecting a candidate did not promote it for the next matching input.");
|
||
|
||
type(session, "nihao");
|
||
const auto first_bmp =
|
||
session.select_candidate_edge(candidate_index(session, "拟好"), metasequoia::CandidateEdge::FirstHan);
|
||
require(first_bmp.handled && first_bmp.commit == "拟" && !session.has_composition(),
|
||
"FirstHan did not commit the first BMP Han character and reset the composition.");
|
||
|
||
type(session, "nihao");
|
||
const auto last_bmp =
|
||
session.select_candidate_edge(candidate_index(session, "拟好"), metasequoia::CandidateEdge::LastHan);
|
||
require(last_bmp.handled && last_bmp.commit == "好" && !session.has_composition(),
|
||
"LastHan did not commit the last BMP Han character and reset the composition.");
|
||
|
||
type(session, "nihao");
|
||
const auto first_supplementary =
|
||
session.select_candidate_edge(candidate_index(session, "𠀀方案𠮷"), metasequoia::CandidateEdge::FirstHan);
|
||
require(first_supplementary.handled && first_supplementary.commit == "𠀀" && !session.has_composition(),
|
||
"FirstHan split a supplementary-plane Han character.");
|
||
|
||
type(session, "nihao");
|
||
const auto last_supplementary =
|
||
session.select_candidate_edge(candidate_index(session, "𠀀方案𠮷"), metasequoia::CandidateEdge::LastHan);
|
||
require(last_supplementary.handled && last_supplementary.commit == "𠮷" && !session.has_composition(),
|
||
"LastHan split a supplementary-plane Han character.");
|
||
|
||
type(session, "nihao");
|
||
const auto first_mixed =
|
||
session.select_candidate_edge(candidate_index(session, "C语言 2"), metasequoia::CandidateEdge::FirstHan);
|
||
require(first_mixed.handled && first_mixed.commit == "语", "FirstHan did not skip a non-Han candidate prefix.");
|
||
|
||
type(session, "nihao");
|
||
const auto last_mixed =
|
||
session.select_candidate_edge(candidate_index(session, "C语言 2"), metasequoia::CandidateEdge::LastHan);
|
||
require(last_mixed.handled && last_mixed.commit == "言", "LastHan did not skip a non-Han candidate suffix.");
|
||
|
||
type(session, "nihao");
|
||
const auto no_han =
|
||
session.select_candidate_edge(candidate_index(session, "GitHub"), metasequoia::CandidateEdge::FirstHan);
|
||
require(!no_han.handled && session.has_composition(),
|
||
"A candidate without Han characters was consumed by edge selection.");
|
||
session.handle_command(metasequoia::Command::Cancel);
|
||
|
||
type(session, "nihao");
|
||
session.handle_command(metasequoia::Command::Backspace);
|
||
require(session.preedit() == "niha", "Backspace did not remove the last pinyin character.");
|
||
const auto raw = session.handle_command(metasequoia::Command::CommitRaw);
|
||
require(raw.handled && raw.commit == "niha", "CommitRaw did not commit the typed input.");
|
||
|
||
type(session, "nihao");
|
||
const auto cancel = session.handle_command(metasequoia::Command::Cancel);
|
||
require(cancel.handled && !cancel.commit.has_value() && !session.has_composition(),
|
||
"Cancel did not discard the composition.");
|
||
|
||
type(session, "nihao");
|
||
session.switch_scheme(SchemeType::Wubi);
|
||
require(session.scheme() == SchemeType::Wubi, "Switching to Wubi did not update the active scheme.");
|
||
require(!session.has_composition() && session.candidates().empty(),
|
||
"Switching schemes did not discard the old composition.");
|
||
|
||
session.switch_scheme(SchemeType::JapaneseRomaji);
|
||
require(session.scheme() == SchemeType::JapaneseRomaji,
|
||
"Switching without a composition did not update the active scheme.");
|
||
session.switch_scheme(SchemeType::Quanpin);
|
||
|
||
const std::vector<std::string> supported_helpcode_schemas{"lantian", "ziranma", "shouyou2_0",
|
||
"shouyouplus", "xiaohe", "jiajia"};
|
||
for (const std::string &schema : supported_helpcode_schemas)
|
||
{
|
||
require(metasequoia::InputSession::is_supported_helpcode_schema(schema) &&
|
||
metasequoia::InputSession::select_helpcode_schema(schema),
|
||
"A Windows-supported helpcode schema was rejected.");
|
||
}
|
||
require(!metasequoia::InputSession::is_supported_helpcode_schema("unknown") &&
|
||
!metasequoia::InputSession::select_helpcode_schema("unknown"),
|
||
"An unknown helpcode schema was accepted.");
|
||
// User tables in helpcodes/custom: UTF-8 file names, a BOM, CRLF and a full-width colon in the header.
|
||
const std::filesystem::path custom_directory = data_directory / "helpcodes" / "custom";
|
||
write_file(custom_directory / std::filesystem::u8path("我的码.txt"),
|
||
"\xEF\xBB\xBF# name\xEF\xBC\x9A 我的辅助码\n# name_en: Mine\n你=cb\n拟=ab\n好=ef\n");
|
||
write_file(custom_directory / "plain.txt", "你=ab\n");
|
||
write_file(custom_directory / "README.md", "# name: not a schema\n");
|
||
const auto custom_schemas = HelpcodeUtils::list_custom_helpcode_schemas(data_directory);
|
||
require(custom_schemas.size() == 2 && custom_schemas[0].schema == "custom/plain" &&
|
||
custom_schemas[0].name.empty() && custom_schemas[0].name_en.empty() &&
|
||
custom_schemas[1].schema == "custom/我的码" && custom_schemas[1].name == "我的辅助码" &&
|
||
custom_schemas[1].name_en == "Mine",
|
||
"Custom helpcode schemas were not discovered with their header names.");
|
||
require(HelpcodeUtils::is_helpcode_schema_available(data_directory, "custom/我的码") &&
|
||
HelpcodeUtils::is_helpcode_schema_available(data_directory, "lantian") &&
|
||
!HelpcodeUtils::is_helpcode_schema_available(data_directory, "custom/missing"),
|
||
"Custom helpcode availability did not follow the files on disk.");
|
||
for (const std::string schema : {"custom/", "custom/../helpcode", "custom/a\\b", "custom/.hidden"})
|
||
{
|
||
require(!metasequoia::InputSession::is_supported_helpcode_schema(schema),
|
||
"A custom helpcode schema escaping its directory was accepted.");
|
||
}
|
||
const auto custom_keymap = HelpcodeUtils::load_helpcode_keymap(data_directory, "custom/我的码");
|
||
require(custom_keymap->size() == 3 && custom_keymap->at("你") == "cb",
|
||
"A custom helpcode table was not loaded past its header and BOM.");
|
||
{
|
||
metasequoia::InputSession custom_helpcode(SchemeType::Quanpin);
|
||
custom_helpcode.set_quanpin_helpcode_enabled(true);
|
||
require(custom_helpcode.set_helpcode_schema("custom/我的码"), "A custom helpcode schema was not selected.");
|
||
type(custom_helpcode, "nihaoC");
|
||
require(!custom_helpcode.candidates().empty() && custom_helpcode.candidates().front().word == "你好",
|
||
"A custom helpcode table did not drive candidate reordering.");
|
||
}
|
||
|
||
metasequoia::InputSession quanpin_helpcode(SchemeType::Quanpin);
|
||
quanpin_helpcode.set_quanpin_helpcode_enabled(true);
|
||
require(quanpin_helpcode.set_helpcode_schema("lantian"), "The Lantian helpcode fixture was not selected.");
|
||
type(quanpin_helpcode, "nihaoC");
|
||
require(!quanpin_helpcode.candidates().empty() && quanpin_helpcode.candidates().front().word == "拟好",
|
||
"Quanpin helpcode did not reorder candidates after a complete spelling.");
|
||
quanpin_helpcode.handle_command(metasequoia::Command::Cancel);
|
||
type(quanpin_helpcode, "nihC");
|
||
metasequoia::InputSession quanpin_without_helpcode(SchemeType::Quanpin);
|
||
quanpin_without_helpcode.set_quanpin_helpcode_enabled(false);
|
||
require(!quanpin_without_helpcode.helpcode_enabled(),
|
||
"Disabling Quanpin helpcode was not reflected by the session.");
|
||
type(quanpin_without_helpcode, "nih");
|
||
require(!quanpin_without_helpcode.handle_character('C').handled && quanpin_without_helpcode.preedit() == "nih",
|
||
"A setter-disabled Quanpin helpcode key was swallowed.");
|
||
require(same_candidate_words(quanpin_helpcode, quanpin_without_helpcode),
|
||
"Quanpin helpcode changed candidates after an incomplete base spelling.");
|
||
|
||
metasequoia::InputSession shuangpin_helpcode(SchemeType::Shuangpin);
|
||
shuangpin_helpcode.set_shuangpin_helpcode_enabled(true);
|
||
type(shuangpin_helpcode, "nihcc");
|
||
require(shuangpin_helpcode.raw_segmentation() == "ni'hc'c" &&
|
||
shuangpin_helpcode.normalized_segmentation() == "ni'hao'c" &&
|
||
!shuangpin_helpcode.candidates().empty() && shuangpin_helpcode.candidates().front().word == "拟好",
|
||
"Shuangpin helpcode or exposed segmentation did not match the complete base spelling.");
|
||
|
||
metasequoia::InputSession shuangpin_delimited_helpcode(SchemeType::Shuangpin);
|
||
shuangpin_delimited_helpcode.set_shuangpin_helpcode_enabled(true);
|
||
type(shuangpin_delimited_helpcode, "nihcAB'");
|
||
require(shuangpin_delimited_helpcode.raw_segmentation() == "ni'hc'AB" &&
|
||
shuangpin_delimited_helpcode.normalized_segmentation() == "ni'hao'AB",
|
||
"A manual delimiter next to the Shuangpin help codes leaked into the exposed segmentation.");
|
||
|
||
metasequoia::InputSession shuangpin_without_helpcode(SchemeType::Shuangpin);
|
||
shuangpin_without_helpcode.set_shuangpin_helpcode_enabled(false);
|
||
require(!shuangpin_without_helpcode.helpcode_enabled(),
|
||
"Disabling Shuangpin helpcode was not reflected by the session.");
|
||
type(shuangpin_without_helpcode, "ni");
|
||
require(!shuangpin_without_helpcode.handle_character('H').handled &&
|
||
shuangpin_without_helpcode.preedit() == "ni",
|
||
"A setter-disabled Shuangpin helpcode key was swallowed.");
|
||
|
||
// 句中辅助码:反引号后的第一码筛它前面那个音节上的字(lantian 夹具:你=ab、拟=cd、好=ef)。
|
||
{
|
||
const auto has_word = [](const metasequoia::InputSession &target, const std::string &word) {
|
||
const auto &items = target.candidates();
|
||
return std::any_of(items.begin(), items.end(), [&](const WordItem &item) { return item.word == word; });
|
||
};
|
||
const auto enabled_session = []() {
|
||
auto session = std::make_unique<metasequoia::InputSession>(SchemeType::Shuangpin);
|
||
session->set_mid_sentence_helpcode_enabled(true);
|
||
require(session->set_helpcode_schema("lantian"), "The Lantian helpcode fixture was not selected.");
|
||
return session;
|
||
};
|
||
|
||
metasequoia::InputSession disabled(SchemeType::Shuangpin);
|
||
type(disabled, "ni");
|
||
require(!disabled.handle_character('`').handled && disabled.preedit() == "ni",
|
||
"A backquote entered the composition while mid-sentence helpcode was off.");
|
||
|
||
auto first_code_session = enabled_session();
|
||
auto &first_code = *first_code_session;
|
||
type(first_code, "n");
|
||
require(!first_code.handle_character('`').handled,
|
||
"A backquote was accepted after an incomplete shuangpin syllable.");
|
||
type(first_code, "i`c");
|
||
require(!first_code.handle_character('`').handled,
|
||
"A second backquote was accepted right after a helpcode block.");
|
||
type(first_code, "hc");
|
||
require(first_code.preedit() == "ni`chc" && first_code.get_pinyin_sequence_with_cases() == "ni`chc" &&
|
||
first_code.get_pinyin_sequence() == "ni'hc",
|
||
"The mid-sentence helpcode block was not kept in the raw input.");
|
||
require(first_code.get_pinyin_segmentation_with_cases() == "ni`c'hc",
|
||
"The mid-sentence helpcode block was not shown after its syllable.");
|
||
require(has_word(first_code, "拟好") && !has_word(first_code, "你好"),
|
||
"The mid-sentence first code did not filter the constrained syllable.");
|
||
|
||
auto second_code_session = enabled_session();
|
||
auto &second_code = *second_code_session;
|
||
type(second_code, "ni`cDhc");
|
||
require(has_word(second_code, "拟好") && !has_word(second_code, "你好"),
|
||
"A matching uppercase second code filtered the constrained syllable away.");
|
||
auto wrong_second_code_session = enabled_session();
|
||
auto &wrong_second_code = *wrong_second_code_session;
|
||
type(wrong_second_code, "ni`cEhc");
|
||
require(!has_word(wrong_second_code, "拟好") && !has_word(wrong_second_code, "你好"),
|
||
"An uppercase second code did not take part in filtering.");
|
||
|
||
// 约束挂在后面的音节上:前面的短候选不受影响,选掉之后约束随剩余部分留下来。
|
||
auto carried_session = enabled_session();
|
||
auto &carried = *carried_session;
|
||
type(carried, "nihcbuhc`x");
|
||
require(has_word(carried, "你好"), "A constraint on a later syllable filtered a shorter candidate.");
|
||
const auto committed = carried.select_candidate(candidate_index(carried, "你好"));
|
||
require(committed.commit == "你好" && carried.preedit() == "buhc`x",
|
||
"Selecting a prefix dropped the helpcode block of the remaining syllables.");
|
||
require(!has_word(carried, "不好") && !has_word(carried, "补好"),
|
||
"The carried mid-sentence helpcode no longer filtered the remaining syllables.");
|
||
require(carried.handle_command(metasequoia::Command::Backspace).handled && carried.preedit() == "buhc`",
|
||
"Backspace did not remove the helpcode letter.");
|
||
require(has_word(carried, "不好") && has_word(carried, "补好"),
|
||
"A bare backquote still filtered candidates.");
|
||
|
||
// 光标移回句中补辅助码:反引号和它后面的码按光标前的部分判断,插在光标处。
|
||
auto caret_session = enabled_session();
|
||
auto &caret = *caret_session;
|
||
type(caret, "nihc");
|
||
require(caret.handle_command(metasequoia::Command::MoveLeft).handled && caret.caret_position() == 3,
|
||
"The caret did not move into the composition.");
|
||
require(!caret.handle_character('`').handled && caret.preedit() == "nihc",
|
||
"A backquote was accepted in the middle of a shuangpin syllable.");
|
||
require(caret.handle_command(metasequoia::Command::MoveLeft).handled && caret.caret_position() == 2,
|
||
"The caret did not move back to the syllable boundary.");
|
||
require(caret.handle_character('`').handled && caret.preedit() == "ni`hc" && caret.caret_position() == 3,
|
||
"A backquote at a mid-composition syllable boundary was not inserted at the caret.");
|
||
require(!caret.handle_character('`').handled && caret.preedit() == "ni`hc",
|
||
"A second backquote was accepted right after a mid-composition marker.");
|
||
type(caret, "cD");
|
||
require(caret.preedit() == "ni`cDhc" && caret.caret_position() == 5,
|
||
"The mid-composition helpcode letters were not inserted at the caret.");
|
||
// 辅码段是音节后面单独的一个单元:按段删除、按段跳光标都能只落在它上面。
|
||
require(caret.segment_raw_boundaries() == std::vector<std::size_t>({0, 2, 5, 7}),
|
||
"The mid-sentence helpcode block was not a unit of its own.");
|
||
// 光标停在辅码段后面:候选跟着光标前缀走,不再是整串的词;回到串尾再按整串解码。
|
||
require(!has_word(caret, "拟好") && !has_word(caret, "你好"),
|
||
"The caret prefix after a mid-sentence helpcode block was not decoded on its own.");
|
||
require(caret.handle_command(metasequoia::Command::MoveEnd).handled, "The caret did not move to the end.");
|
||
require(has_word(caret, "拟好") && !has_word(caret, "你好"),
|
||
"A helpcode inserted in the middle did not filter its syllable.");
|
||
for (int step = 0; step < 2; ++step)
|
||
require(caret.handle_command(metasequoia::Command::MoveLeft).handled, "The caret did not move left.");
|
||
for (int step = 0; step < 3; ++step)
|
||
require(caret.handle_command(metasequoia::Command::MoveLeft).handled, "The caret did not move left.");
|
||
require(caret.caret_position() == 2 && !caret.handle_character('`').handled && caret.preedit() == "ni`cDhc",
|
||
"A backquote was accepted in front of an existing helpcode block.");
|
||
}
|
||
|
||
// 句中辅助码的大写触发:完整音节后的大写字母相当于「反引号 + 这个字母」,第二码仍要大写。
|
||
{
|
||
const auto has_word = [](const metasequoia::InputSession &target, const std::string &word) {
|
||
const auto &items = target.candidates();
|
||
return std::any_of(items.begin(), items.end(), [&](const WordItem &item) { return item.word == word; });
|
||
};
|
||
const auto uppercase_session = [](bool uppercase, const ShuangpinProfile &profile) {
|
||
auto session = std::make_unique<metasequoia::InputSession>(SchemeType::Shuangpin, profile,
|
||
metasequoia::RuntimePaths::legacy());
|
||
session->set_shuangpin_helpcode_enabled(false);
|
||
session->set_mid_sentence_helpcode_enabled(true);
|
||
session->set_mid_sentence_uppercase_trigger_enabled(uppercase);
|
||
require(session->set_helpcode_schema("lantian"), "The Lantian helpcode fixture was not selected.");
|
||
return session;
|
||
};
|
||
|
||
auto first_session = uppercase_session(true, GetXiaoheShuangpinProfile());
|
||
auto &first = *first_session;
|
||
type(first, "niChc");
|
||
require(first.preedit() == "niChc" && first.get_pinyin_sequence_with_cases() == "niChc" &&
|
||
first.get_pinyin_sequence() == "ni'hc",
|
||
"An uppercase letter after a complete syllable did not open a mid-sentence helpcode block.");
|
||
require(first.get_pinyin_segmentation_with_cases() == "niC'hc",
|
||
"The uppercase helpcode block was not shown after its syllable.");
|
||
require(first.segment_raw_boundaries() == std::vector<std::size_t>({0, 2, 3, 5}),
|
||
"The uppercase helpcode block was not a unit of its own.");
|
||
require(has_word(first, "拟好") && !has_word(first, "你好"),
|
||
"The uppercase first code did not filter the constrained syllable.");
|
||
// 大写段之后照样能接反引号段,这一节从大写段之后算起。
|
||
require(first.handle_character('`').handled && first.preedit() == "niChc`",
|
||
"A backquote was rejected after an uppercase helpcode block.");
|
||
|
||
auto second_session = uppercase_session(true, GetXiaoheShuangpinProfile());
|
||
type(*second_session, "niCDhc");
|
||
require(has_word(*second_session, "拟好") && !has_word(*second_session, "你好"),
|
||
"A matching uppercase second code after an uppercase block filtered the syllable away.");
|
||
auto wrong_second_session = uppercase_session(true, GetXiaoheShuangpinProfile());
|
||
type(*wrong_second_session, "niCEhc");
|
||
require(!has_word(*wrong_second_session, "拟好") && !has_word(*wrong_second_session, "你好"),
|
||
"An uppercase second code after an uppercase block did not take part in filtering.");
|
||
|
||
// 只有在完整音节后才开段:音节中间的大写字母不收(辅助码开关关着时)。
|
||
auto middle_session = uppercase_session(true, GetXiaoheShuangpinProfile());
|
||
type(*middle_session, "n");
|
||
require(!middle_session->handle_character('C').handled && middle_session->preedit() == "n",
|
||
"An uppercase letter in the middle of a syllable was accepted as a helpcode.");
|
||
|
||
// 勾选关着:大写字母不开段,原来的规则不变。
|
||
auto off_session = uppercase_session(false, GetXiaoheShuangpinProfile());
|
||
type(*off_session, "ni");
|
||
require(!off_session->handle_character('C').handled && off_session->preedit() == "ni",
|
||
"An uppercase letter opened a block while the uppercase trigger was off.");
|
||
|
||
// 约束挂在后面的音节上:选掉前面的词,大写段随剩余部分留下来。
|
||
auto carried_session = uppercase_session(true, GetXiaoheShuangpinProfile());
|
||
auto &carried = *carried_session;
|
||
type(carried, "nihcbuhcX");
|
||
require(has_word(carried, "你好"), "An uppercase block on a later syllable filtered a shorter candidate.");
|
||
const auto committed = carried.select_candidate(candidate_index(carried, "你好"));
|
||
require(committed.commit == "你好" && carried.preedit() == "buhcX",
|
||
"Selecting a prefix dropped the uppercase block of the remaining syllables.");
|
||
require(!has_word(carried, "不好") && !has_word(carried, "补好"),
|
||
"The carried uppercase block no longer filtered the remaining syllables.");
|
||
|
||
// 微软双拼的 ; 韵母:大写段不算这一节的键,nihkXb; 里 b; 是一个音节。
|
||
auto microsoft_session = uppercase_session(true, GetMicrosoftShuangpinProfile());
|
||
type(*microsoft_session, "nihkXb");
|
||
require(microsoft_session->handle_character(';').handled && microsoft_session->preedit() == "nihkXb;",
|
||
"The semicolon final was rejected after an uppercase helpcode block.");
|
||
}
|
||
|
||
// 双拼末尾辅助码关掉直接触发:辅码改由引导键(输入串里的 |,宿主把反引号或分号换成它)或大写字母
|
||
// 引出,一码按首末字重排、两码按首末字筛选(lantian 夹具:你=ab、拟=cd、好=ef)。
|
||
{
|
||
const auto has_word = [](const metasequoia::InputSession &target, const std::string &word) {
|
||
const auto &items = target.candidates();
|
||
return std::any_of(items.begin(), items.end(), [&](const WordItem &item) { return item.word == word; });
|
||
};
|
||
const auto indirect_session = [](bool uppercase, const ShuangpinProfile &profile) {
|
||
auto session = std::make_unique<metasequoia::InputSession>(SchemeType::Shuangpin, profile,
|
||
metasequoia::RuntimePaths::legacy());
|
||
session->set_shuangpin_helpcode_enabled(true);
|
||
session->set_shuangpin_helpcode_triggers(false, uppercase);
|
||
require(session->set_helpcode_schema("lantian"), "The Lantian helpcode fixture was not selected.");
|
||
return session;
|
||
};
|
||
|
||
// 关着直接触发:完整音节后多出来的键不再当单码。
|
||
auto plain_session = indirect_session(false, GetXiaoheShuangpinProfile());
|
||
type(*plain_session, "nihcc");
|
||
require(!plain_session->has_active_helpcode(),
|
||
"A trailing letter was read as a helpcode while the end helpcode was indirect.");
|
||
|
||
auto single_session = indirect_session(false, GetXiaoheShuangpinProfile());
|
||
auto &single = *single_session;
|
||
type(single, "n");
|
||
require(!single.handle_character('|').handled && single.preedit() == "n",
|
||
"An end helpcode marker was accepted after an incomplete shuangpin syllable.");
|
||
type(single, "ihc");
|
||
require(single.handle_character('|').handled && single.preedit() == "nihc|",
|
||
"The end helpcode marker was not accepted after complete syllables.");
|
||
require(!single.handle_character('|').handled, "A second end helpcode marker was accepted.");
|
||
type(single, "c");
|
||
require(single.get_pinyin_sequence_with_cases() == "nihc|c" && single.get_pinyin_sequence() == "nihc" &&
|
||
single.has_active_helpcode(),
|
||
"The end helpcode block was not kept apart from the pinyin.");
|
||
require(single.get_pinyin_segmentation_with_cases() == "ni'hc|c",
|
||
"The end helpcode block was not shown after the last syllable.");
|
||
require(single.segment_raw_boundaries() == std::vector<std::size_t>({0, 2, 4, 6}),
|
||
"The end helpcode block was not a unit of its own.");
|
||
require(!single.candidates().empty() && single.candidates().front().word == "拟好" &&
|
||
has_word(single, "你好"),
|
||
"A single indirect end helpcode did not reorder candidates by their first character.");
|
||
|
||
auto double_session = indirect_session(false, GetXiaoheShuangpinProfile());
|
||
type(*double_session, "nihc|cE");
|
||
require(has_word(*double_session, "拟好") && !has_word(*double_session, "你好"),
|
||
"Two indirect end helpcodes did not filter by the first and last characters.");
|
||
|
||
// 引导键只在输入末尾收:光标移回句中时仍按标点处理。
|
||
auto caret_session = indirect_session(false, GetXiaoheShuangpinProfile());
|
||
type(*caret_session, "nihc");
|
||
require(caret_session->handle_command(metasequoia::Command::MoveLeft).handled &&
|
||
caret_session->handle_command(metasequoia::Command::MoveLeft).handled &&
|
||
!caret_session->handle_character('|').handled && caret_session->preedit() == "nihc",
|
||
"An end helpcode marker was accepted in the middle of the composition.");
|
||
|
||
// 选掉前面的词,末尾段随剩余部分留下来;退格先删辅码。
|
||
auto carried_session = indirect_session(false, GetXiaoheShuangpinProfile());
|
||
auto &carried = *carried_session;
|
||
type(carried, "nihcbuhc|x");
|
||
require(has_word(carried, "你好"), "A single end helpcode filtered a shorter candidate away.");
|
||
const auto committed = carried.select_candidate(candidate_index(carried, "你好"));
|
||
require(committed.commit == "你好" && carried.preedit() == "buhc|x" && carried.has_active_helpcode(),
|
||
"Selecting a prefix dropped the end helpcode block of the remaining syllables.");
|
||
require(carried.handle_command(metasequoia::Command::Backspace).handled && carried.preedit() == "buhc|" &&
|
||
!carried.has_active_helpcode(),
|
||
"Backspace did not remove the end helpcode letter.");
|
||
|
||
// 大写触发:完整音节后的大写字母开一段末尾段,第二码大小写都算。
|
||
auto upper_session = indirect_session(true, GetXiaoheShuangpinProfile());
|
||
auto &upper = *upper_session;
|
||
type(upper, "nihcCe");
|
||
require(upper.get_pinyin_sequence() == "nihc" && upper.get_pinyin_segmentation_with_cases() == "ni'hcCe",
|
||
"An uppercase letter after complete syllables did not open an end helpcode block.");
|
||
require(has_word(upper, "拟好") && !has_word(upper, "你好"),
|
||
"An uppercase end helpcode block did not filter candidates.");
|
||
|
||
// 微软双拼:末尾段后面的 ; 不是韵母。
|
||
auto microsoft_session = indirect_session(false, GetMicrosoftShuangpinProfile());
|
||
type(*microsoft_session, "nihk|ce");
|
||
require(!microsoft_session->handle_character(';').handled && microsoft_session->preedit() == "nihk|ce",
|
||
"A semicolon after an end helpcode block was taken as the ing final.");
|
||
|
||
// 直接触发开着、或直接辅助码(万象式)接管辅码时,引导键不收。
|
||
auto direct_session = indirect_session(false, GetXiaoheShuangpinProfile());
|
||
direct_session->set_shuangpin_helpcode_triggers(true, false);
|
||
type(*direct_session, "nihc");
|
||
require(!direct_session->handle_character('|').handled,
|
||
"An end helpcode marker was accepted while the end helpcode was direct.");
|
||
auto wanxiang_session = indirect_session(false, GetXiaoheShuangpinProfile());
|
||
wanxiang_session->set_direct_helpcode_enabled(true);
|
||
type(*wanxiang_session, "nihc");
|
||
require(!wanxiang_session->handle_character('|').handled,
|
||
"An end helpcode marker was accepted while the direct helpcode took over.");
|
||
}
|
||
|
||
require(!session.handle_character('1').handled, "A digit was swallowed instead of passed through.");
|
||
require(!session.handle_command(metasequoia::Command::Backspace).handled,
|
||
"Backspace was swallowed while no composition was active.");
|
||
require(!session.handle_command(metasequoia::Command::CommitRaw).handled,
|
||
"CommitRaw was swallowed while no composition was active.");
|
||
require(!session.select_candidate(static_cast<std::size_t>(0)).handled,
|
||
"A candidate was selected while no composition was active.");
|
||
|
||
require(!session.handle_character('\'').handled, "An idle apostrophe was swallowed.");
|
||
}
|
||
|
||
run_umlaut_alias_session_tests(data_directory);
|
||
run_caret_prefix_session_tests(data_directory);
|
||
run_autocorrect_mixed_jianpin_tests(data_directory);
|
||
run_literal_mixed_pinyin_tests(data_directory);
|
||
run_autocorrect_context_ranking_tests(data_directory);
|
||
run_autocorrect_context_layering_tests(data_directory);
|
||
run_autocorrect_context_user_choice_tests(data_directory);
|
||
run_legal_input_correction_tests(data_directory);
|
||
run_autocorrect_selection_consumption_tests(data_directory);
|
||
run_literal_segmentation_arbitration_tests(data_directory);
|
||
run_autocorrect_costlier_context_tests(data_directory);
|
||
run_autocorrect_off_consistency_tests(data_directory);
|
||
|
||
run_autocorrect_generated_space_tests(data_directory);
|
||
run_autocorrect_pure_jianpin_guard_tests(data_directory);
|
||
#endif
|
||
|
||
#ifndef METASEQUOIA_SKIP_FREQUENCY_TESTS
|
||
struct FrequencyCase
|
||
{
|
||
metasequoia::FrequencyAdjustmentMode mode;
|
||
const char *name;
|
||
std::size_t expected_index;
|
||
int linear_step;
|
||
};
|
||
const std::array frequency_cases{
|
||
FrequencyCase{metasequoia::FrequencyAdjustmentMode::Disabled, "disabled", 5, 1},
|
||
FrequencyCase{metasequoia::FrequencyAdjustmentMode::Pin, "pin", 0, 1},
|
||
FrequencyCase{metasequoia::FrequencyAdjustmentMode::Halve, "halve", 2, 1},
|
||
FrequencyCase{metasequoia::FrequencyAdjustmentMode::Linear, "linear", 3, 2},
|
||
FrequencyCase{metasequoia::FrequencyAdjustmentMode::Promote, "promote", 4, 1},
|
||
};
|
||
for (const FrequencyCase &frequency_case : frequency_cases)
|
||
{
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path directory = data_directory / (std::string("frequency-") + frequency_case.name);
|
||
prepare_frequency_fixture(directory);
|
||
set_data_directory(directory);
|
||
|
||
metasequoia::InputSession learning_session(SchemeType::Quanpin);
|
||
require(learning_session.set_frequency_adjustment({frequency_case.mode, 1, frequency_case.linear_step}),
|
||
"A supported frequency adjustment configuration was rejected.");
|
||
type(learning_session, "ni");
|
||
const auto learned = learning_session.select_candidate(std::string("己"));
|
||
require(learned.handled && learned.commit == "己" && !learned.diagnostic.has_value(),
|
||
"Frequency learning changed or diagnosed a successful candidate commit.");
|
||
|
||
metasequoia::InputSession reopened(SchemeType::Quanpin);
|
||
type(reopened, "ni");
|
||
require(candidate_index(reopened, "己") == frequency_case.expected_index,
|
||
"A frequency mode did not persist the Windows-compatible ranking transition.");
|
||
const bool journal_exists = std::filesystem::exists(directory / "msime_user.db");
|
||
require(journal_exists == (frequency_case.mode != metasequoia::FrequencyAdjustmentMode::Disabled),
|
||
"Frequency learning wrote an unexpected user journal state.");
|
||
}
|
||
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path trigger_directory = data_directory / "frequency-trigger";
|
||
prepare_frequency_fixture(trigger_directory);
|
||
set_data_directory(trigger_directory);
|
||
for (int selection = 0; selection < 2; ++selection)
|
||
{
|
||
metasequoia::InputSession triggered(SchemeType::Quanpin);
|
||
require(triggered.set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Pin, 2, 1}),
|
||
"A valid trigger-count configuration was rejected.");
|
||
type(triggered, "ni");
|
||
require(triggered.select_candidate(std::string("己")).commit == "己",
|
||
"A deferred frequency adjustment blocked candidate commit.");
|
||
|
||
metasequoia::InputSession observed(SchemeType::Quanpin);
|
||
type(observed, "ni");
|
||
require(candidate_index(observed, "己") == (selection == 0 ? 5U : 0U),
|
||
"Frequency trigger_count did not defer exactly the configured number of selections.");
|
||
}
|
||
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path first_candidate_directory = data_directory / "frequency-first-candidate";
|
||
prepare_frequency_fixture(first_candidate_directory);
|
||
set_data_directory(first_candidate_directory);
|
||
metasequoia::InputSession first_candidate_session(SchemeType::Quanpin);
|
||
require(first_candidate_session.set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Pin, 1, 1}),
|
||
"A valid first-candidate learning configuration was rejected.");
|
||
type(first_candidate_session, "ni");
|
||
require(first_candidate_session.select_candidate(static_cast<std::size_t>(0)).commit == "甲" &&
|
||
!std::filesystem::exists(first_candidate_directory / "msime_user.db"),
|
||
"Selecting the already-leading candidate created frequency state.");
|
||
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path shuangpin_directory = data_directory / "frequency-shuangpin";
|
||
prepare_shuangpin_frequency_fixture(shuangpin_directory);
|
||
set_data_directory(shuangpin_directory);
|
||
metasequoia::InputSession shuangpin_learning(SchemeType::Shuangpin);
|
||
require(shuangpin_learning.set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Pin, 1, 1}),
|
||
"A valid Shuangpin frequency configuration was rejected.");
|
||
type(shuangpin_learning, "nihc");
|
||
require(shuangpin_learning.select_candidate(std::string("拟好")).commit == "拟好",
|
||
"Shuangpin frequency learning blocked candidate commit.");
|
||
metasequoia::InputSession reopened_shuangpin(SchemeType::Shuangpin);
|
||
type(reopened_shuangpin, "nihc");
|
||
require(!reopened_shuangpin.candidates().empty() && reopened_shuangpin.candidates().front().word == "拟好",
|
||
"Shuangpin frequency learning did not persist through the canonical pinyin key.");
|
||
|
||
// 句中辅助码筛过的列表里,要的词排第一;直接上屏也要按不带约束的排序调频,下次不敲辅助码
|
||
// 它就排在前面(lantian 夹具:你=ab、拟=cd)。
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path mid_sentence_directory = data_directory / "frequency-shuangpin-mid-sentence";
|
||
prepare_shuangpin_frequency_fixture(mid_sentence_directory);
|
||
write_file(mid_sentence_directory / "helpcodes" / "helpcode.txt", "你=ab\n拟=cd\n好=ef\n");
|
||
set_data_directory(mid_sentence_directory);
|
||
{
|
||
metasequoia::InputSession mid_sentence_learning(SchemeType::Shuangpin);
|
||
mid_sentence_learning.set_mid_sentence_helpcode_enabled(true);
|
||
require(mid_sentence_learning.set_helpcode_schema("lantian") &&
|
||
mid_sentence_learning.set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Pin, 1, 1}),
|
||
"The mid-sentence frequency fixture could not be configured.");
|
||
type(mid_sentence_learning, "ni`chc");
|
||
require(candidate_index(mid_sentence_learning, "拟好") == 0,
|
||
"The mid-sentence helpcode did not put the constrained phrase first.");
|
||
const auto learned = mid_sentence_learning.select_candidate(static_cast<std::size_t>(0));
|
||
require(learned.commit == "拟好" && !learned.diagnostic.has_value(),
|
||
"Committing a mid-sentence helpcode phrase failed.");
|
||
}
|
||
metasequoia::InputSession reopened_mid_sentence(SchemeType::Shuangpin);
|
||
type(reopened_mid_sentence, "nihc");
|
||
require(candidate_index(reopened_mid_sentence, "拟好") == 0,
|
||
"A phrase committed with a mid-sentence helpcode was not promoted for the plain spelling.");
|
||
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path wubi_directory = data_directory / "frequency-wubi";
|
||
prepare_wubi_frequency_fixture(wubi_directory);
|
||
set_data_directory(wubi_directory);
|
||
metasequoia::InputSession wubi_learning(SchemeType::Wubi);
|
||
require(wubi_learning.set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Pin, 1, 1}),
|
||
"A valid Wubi frequency configuration was rejected.");
|
||
type(wubi_learning, "aaaa");
|
||
require(wubi_learning.select_candidate(std::string("或")).commit == "或",
|
||
"Wubi frequency learning blocked candidate commit.");
|
||
metasequoia::InputSession reopened_wubi(SchemeType::Wubi);
|
||
type(reopened_wubi, "aaaa");
|
||
require(!reopened_wubi.candidates().empty() && reopened_wubi.candidates().front().word == "或",
|
||
"Wubi frequency learning did not persist through the Wubi table.");
|
||
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path failure_directory = data_directory / "frequency-write-failure";
|
||
prepare_frequency_fixture(failure_directory);
|
||
std::filesystem::create_directory(failure_directory / "msime_user.db");
|
||
set_data_directory(failure_directory);
|
||
metasequoia::InputSession failing_learning_session(SchemeType::Quanpin);
|
||
require(failing_learning_session.set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Pin, 1, 1}),
|
||
"A valid write-failure learning configuration was rejected.");
|
||
type(failing_learning_session, "ni");
|
||
const auto failure_commit = failing_learning_session.select_candidate(std::string("己"));
|
||
require(failure_commit.handled && failure_commit.commit == "己" && failure_commit.diagnostic.has_value() &&
|
||
failure_commit.diagnostic->find("己") == std::string::npos &&
|
||
failure_commit.diagnostic->find("ni") == std::string::npos,
|
||
"A frequency write failure blocked commit or exposed input text in its diagnostic.");
|
||
|
||
user_dictionary::close_default_user_database();
|
||
const std::filesystem::path partial_write_directory = data_directory / "frequency-partial-write";
|
||
prepare_frequency_fixture(partial_write_directory);
|
||
set_data_directory(partial_write_directory);
|
||
require(user_dictionary::ensure_user_database(user_dictionary::default_user_db_path()),
|
||
"The partial-write fixture could not create the user dictionary schema.");
|
||
user_dictionary::close_default_user_database();
|
||
{
|
||
Database user_database(partial_write_directory / "msime_user.db");
|
||
user_database.execute("CREATE TRIGGER reject_frequency_journal BEFORE INSERT ON user_dictionary_operations "
|
||
"BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END");
|
||
}
|
||
metasequoia::InputSession partial_write_session(SchemeType::Quanpin);
|
||
require(partial_write_session.set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Pin, 1, 1}),
|
||
"A valid partial-write learning configuration was rejected.");
|
||
type(partial_write_session, "ni");
|
||
const auto partial_write_commit = partial_write_session.select_candidate(std::string("己"));
|
||
require(partial_write_commit.handled && partial_write_commit.commit == "己" &&
|
||
partial_write_commit.diagnostic.has_value() &&
|
||
partial_write_commit.diagnostic->find("己") == std::string::npos &&
|
||
partial_write_commit.diagnostic->find("ni") == std::string::npos,
|
||
"A partial frequency write blocked commit or exposed input text in its diagnostic.");
|
||
{
|
||
Database main_database(partial_write_directory / "msime.db");
|
||
require(main_database.query_integer("SELECT weight FROM tbl_1_n WHERE key='ni' AND value='己'") == 50,
|
||
"A failed journal write left a partial frequency update in the main dictionary.");
|
||
}
|
||
|
||
metasequoia::FrequencyAdjustmentOptions invalid_frequency;
|
||
invalid_frequency.mode = static_cast<metasequoia::FrequencyAdjustmentMode>(99);
|
||
require(!failing_learning_session.set_frequency_adjustment(invalid_frequency),
|
||
"An unknown frequency mode was accepted.");
|
||
invalid_frequency = {};
|
||
invalid_frequency.trigger_count = 0;
|
||
require(!failing_learning_session.set_frequency_adjustment(invalid_frequency),
|
||
"An out-of-range frequency trigger count was accepted.");
|
||
invalid_frequency = {};
|
||
invalid_frequency.linear_step = 11;
|
||
require(!failing_learning_session.set_frequency_adjustment(invalid_frequency),
|
||
"An out-of-range frequency linear step was accepted.");
|
||
user_dictionary::close_default_user_database();
|
||
#endif
|
||
|
||
#ifndef METASEQUOIA_FREQUENCY_TESTS_ONLY
|
||
// 拆字查表走数据目录下的 radical/decomposition.txt。写一份最小表,测试不依赖部署数据
|
||
// 目录里的真实表,字频只用来验证「同前缀内高频在前」。
|
||
const std::filesystem::path radical_directory = data_directory / "radical-fixture";
|
||
write_file(radical_directory / "radical" / "decomposition.txt", "# 部件拼音连写<Tab>汉字<Tab>字频\n"
|
||
"ririri\t晶\t106\n"
|
||
"ririri\t曑\t1\n"
|
||
"heng\t一\t18584\n"
|
||
"hengheng\t丁\t900\n");
|
||
set_data_directory(radical_directory);
|
||
|
||
metasequoia::InputSession radical_session(SchemeType::Quanpin);
|
||
require(radical_session.handle_character('U', true).handled &&
|
||
radical_session.local_input_mode() == metasequoia::LocalInputMode::Radical &&
|
||
radical_session.preedit() == "U" && radical_session.candidates().empty(),
|
||
"Shift+U did not enter an empty radical composition.");
|
||
// 非小写字母(数字)被吞:预编辑保持 "U"、候选不变,也不让给普通拼音。
|
||
require(radical_session.handle_character('4').handled && radical_session.preedit() == "U" &&
|
||
radical_session.candidates().empty(),
|
||
"Radical mode accepted or forwarded a non-lowercase-letter key.");
|
||
for (const char character : std::string("ririri"))
|
||
{
|
||
require(radical_session.handle_character(character).handled, "Radical mode rejected a lowercase letter.");
|
||
}
|
||
require(radical_session.preedit() == "Uririri" && !radical_session.candidates().empty() &&
|
||
radical_session.candidates().front().word == "晶" &&
|
||
radical_session.candidates().front().source == CandidateSource::Generated,
|
||
"Radical mode did not rank the highest-frequency decomposition of 晶 first.");
|
||
const auto radical_commit = radical_session.select_candidate(0);
|
||
require(radical_commit.handled && radical_commit.commit == "晶" && !radical_session.has_composition() &&
|
||
radical_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Committing a radical candidate did not leave the local mode.");
|
||
|
||
// 前缀收窄:连写码 heng 的最高频命中是 一(18584,高于任何 heng* 的更长码)。
|
||
require(radical_session.handle_character('U', true).handled, "Radical mode could not be re-entered for prefixing.");
|
||
type(radical_session, "heng");
|
||
require(radical_session.preedit() == "Uheng" && !radical_session.candidates().empty() &&
|
||
radical_session.candidates().front().word == "一",
|
||
"A radical prefix did not return its highest-frequency character first.");
|
||
require(radical_session.handle_command(metasequoia::Command::Cancel).handled &&
|
||
radical_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Cancel did not leave radical mode.");
|
||
|
||
// 大写字母与预编辑里的其它按键都被吞、预编辑不变(沿用「非法字符被吞」的语义)。
|
||
require(radical_session.handle_character('U', true).handled,
|
||
"Radical mode could not be re-entered for illegal-key coverage.");
|
||
require(radical_session.handle_character('A').handled && radical_session.handle_character('9').handled &&
|
||
radical_session.preedit() == "U" && radical_session.candidates().empty(),
|
||
"Radical mode leaked an uppercase letter or digit into the preedit.");
|
||
// Segment boundaries are the engine's unit model: quanpin reports one offset
|
||
// per displayed syllable, and a local mode reports none so the host keeps
|
||
// single-character editing (PRD R4).
|
||
require(radical_session.segment_raw_boundaries().empty(), "A local mode must report no pinyin segment boundaries.");
|
||
require(radical_session.handle_command(metasequoia::Command::Cancel).handled,
|
||
"Cancel did not leave radical mode after the boundary check.");
|
||
|
||
metasequoia::InputSession boundary_session(SchemeType::Quanpin);
|
||
for (const char character : std::string("nihaoma"))
|
||
{
|
||
require(boundary_session.handle_character(character).handled, "Quanpin rejected a letter.");
|
||
}
|
||
require(boundary_session.segment_raw_boundaries() == std::vector<std::size_t>({0, 2, 5, 7}),
|
||
"Quanpin unit boundaries did not follow the displayed syllables.");
|
||
|
||
require(radical_session.handle_character('U', true).handled,
|
||
"Radical prefix was not handled before Backspace coverage.");
|
||
require(radical_session.handle_command(metasequoia::Command::Backspace).handled &&
|
||
!radical_session.has_composition() &&
|
||
radical_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Backspace on a bare radical prefix did not leave the mode.");
|
||
|
||
metasequoia::InputSession plain_uppercase(SchemeType::Quanpin);
|
||
require(!plain_uppercase.handle_character('U').handled && !plain_uppercase.has_composition() &&
|
||
plain_uppercase.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"An uppercase character without Shift-only was swallowed.");
|
||
metasequoia::LocalModeOptions disabled_local_modes;
|
||
disabled_local_modes.radical = false;
|
||
metasequoia::InputSession disabled_radical(SchemeType::Quanpin);
|
||
disabled_radical.set_local_mode_options(disabled_local_modes);
|
||
require(!disabled_radical.handle_character('U', true).handled && !disabled_radical.has_composition() &&
|
||
disabled_radical.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"A disabled radical shortcut swallowed Shift+U.");
|
||
|
||
metasequoia::InputSession wubi_radical(SchemeType::Wubi);
|
||
require(!wubi_radical.handle_character('U', true).handled && !wubi_radical.has_composition() &&
|
||
wubi_radical.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Shift+U was swallowed outside a pinyin scheme.");
|
||
metasequoia::InputSession switch_clears_radical(SchemeType::Shuangpin);
|
||
require(switch_clears_radical.handle_character('U', true).handled &&
|
||
switch_clears_radical.local_input_mode() == metasequoia::LocalInputMode::Radical,
|
||
"Shuangpin could not enter radical mode.");
|
||
switch_clears_radical.switch_scheme(SchemeType::Quanpin);
|
||
require(!switch_clears_radical.has_composition() &&
|
||
switch_clears_radical.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Switching schemes did not clear radical mode.");
|
||
|
||
metasequoia::InputSession date_time_session(SchemeType::Quanpin);
|
||
date_time_session.set_local_date_time_provider(
|
||
[] { return metasequoia::local_modes::LocalDateTime{2026, 8, 9, 0, 14, 30, 0}; });
|
||
require(date_time_session.handle_character('T', true).handled &&
|
||
date_time_session.local_input_mode() == metasequoia::LocalInputMode::DateTime &&
|
||
date_time_session.preedit() == "T" && date_time_session.candidates().empty(),
|
||
"Shift+T did not enter an empty date/time composition.");
|
||
type(date_time_session, "rq");
|
||
require(date_time_session.preedit() == "Trq" && date_time_session.candidates().size() == 17 &&
|
||
date_time_session.candidates().front().word == "2026年8月9日" &&
|
||
date_time_session.candidates().back().word == "丙午年六月二十七日",
|
||
"Date/time mode did not expose deterministic date candidates.");
|
||
const auto date_commit = date_time_session.select_candidate(0);
|
||
require(date_commit.handled && date_commit.commit == "2026年8月9日" &&
|
||
date_time_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Committing a date candidate did not leave date/time mode.");
|
||
|
||
require(date_time_session.handle_character('T', true).handled,
|
||
"Date/time mode could not be re-entered for incomplete-input coverage.");
|
||
type(date_time_session, "r");
|
||
require(date_time_session.candidates().empty(), "An incomplete date keyword produced candidates.");
|
||
const std::string incomplete_date_preedit = date_time_session.preedit();
|
||
require(date_time_session.handle_character('1').handled && date_time_session.preedit() == incomplete_date_preedit,
|
||
"Invalid date/time input leaked into normal composition or changed preedit.");
|
||
require(date_time_session.handle_command(metasequoia::Command::Cancel).handled &&
|
||
date_time_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Cancel did not leave date/time mode.");
|
||
|
||
require(date_time_session.handle_character('T', true).handled &&
|
||
date_time_session.handle_command(metasequoia::Command::Backspace).handled &&
|
||
date_time_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Backspace on a bare date/time prefix did not leave the mode.");
|
||
|
||
// 指定的日期时间:数字、/ 和 : 接得上形状才收,接不上的吞掉,预编辑保持原样。
|
||
require(date_time_session.handle_character('T', true).handled, "Date/time mode could not be re-entered.");
|
||
type(date_time_session, "2024/12/25");
|
||
require(date_time_session.preedit() == "T2024/12/25" && date_time_session.candidates().size() == 15 &&
|
||
date_time_session.candidates().front().word == "2024年12月25日",
|
||
"A specific date did not produce candidates for that day.");
|
||
require(date_time_session.handle_character('1').handled && date_time_session.handle_character(':').handled &&
|
||
date_time_session.preedit() == "T2024/12/25",
|
||
"A key that cannot extend the date leaked into the preedit.");
|
||
require(date_time_session.handle_command(metasequoia::Command::MoveLeft).handled &&
|
||
date_time_session.handle_command(metasequoia::Command::MoveLeft).handled &&
|
||
date_time_session.handle_command(metasequoia::Command::MoveLeft).handled &&
|
||
date_time_session.handle_command(metasequoia::Command::Backspace).handled &&
|
||
date_time_session.preedit() == "T2024/1/25" && date_time_session.handle_character('1').handled &&
|
||
date_time_session.preedit() == "T2024/11/25" &&
|
||
date_time_session.candidates().front().word == "2024年11月25日",
|
||
"Editing a specific date in the middle did not follow the shared shape rule.");
|
||
const auto specific_commit = date_time_session.select_candidate(1);
|
||
require(specific_commit.handled && specific_commit.commit == "2024-11-25" &&
|
||
date_time_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Committing a specific date candidate did not leave date/time mode.");
|
||
require(date_time_session.handle_character('T', true).handled, "Date/time mode could not be re-entered.");
|
||
type(date_time_session, "9:05");
|
||
require(date_time_session.preedit() == "T9:05" && date_time_session.candidates().front().word == "09:05",
|
||
"A specific time did not produce candidates.");
|
||
require(date_time_session.handle_command(metasequoia::Command::Cancel).handled,
|
||
"Cancel did not leave the specific time.");
|
||
|
||
// V 模式由 Server 按原串识别,引擎照常收到整串:运算符和括号混在拼音原串里也不能出错。
|
||
for (const SchemeType scheme : {SchemeType::Quanpin, SchemeType::Shuangpin})
|
||
{
|
||
metasequoia::InputSession v_mode_raw(scheme);
|
||
for (const char *raw : {"V1+2*(3-4)/5.6", "v12.5", "V(", "V9)", "v1//2"})
|
||
{
|
||
v_mode_raw.set_pinyin_sequence(raw);
|
||
v_mode_raw.set_pinyin_sequence_with_cases(raw);
|
||
v_mode_raw.recompute_candidates();
|
||
(void)v_mode_raw.get_candidates();
|
||
}
|
||
}
|
||
|
||
metasequoia::LocalModeOptions disabled_date_time_options;
|
||
disabled_date_time_options.date_time = false;
|
||
metasequoia::InputSession disabled_date_time(SchemeType::Quanpin);
|
||
disabled_date_time.set_local_mode_options(disabled_date_time_options);
|
||
require(!disabled_date_time.handle_character('T', true).handled && !disabled_date_time.has_composition() &&
|
||
disabled_date_time.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"A disabled date/time shortcut swallowed Shift+T.");
|
||
|
||
metasequoia::InputSession wubi_date_time(SchemeType::Wubi);
|
||
require(!wubi_date_time.handle_character('T', true).handled && !wubi_date_time.has_composition() &&
|
||
wubi_date_time.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Shift+T was swallowed outside a pinyin scheme.");
|
||
|
||
// 五笔保留原样串:Server 端的 T/V 形状判定(大写 T 前缀、V+数字)读的是宿主回读的原样串,
|
||
// 而五笔码表查询和自动上屏用的是归一后的码。宿主把 T/V 模式的串标成字面输入,原样串才
|
||
// 留全用户敲的字符;普通码的原样串就是带大小写的码,与查询码逐位对应、一起截断。
|
||
const std::filesystem::path wubi_raw_directory = data_directory / "wubi-raw";
|
||
std::filesystem::create_directories(wubi_raw_directory);
|
||
{
|
||
Database database(wubi_raw_directory / "msime.db");
|
||
database.execute("CREATE TABLE wubi86(key TEXT, value TEXT, weight INTEGER)");
|
||
}
|
||
set_data_directory(wubi_raw_directory);
|
||
for (const bool mixed_pinyin : {false, true})
|
||
{
|
||
metasequoia::WubiInputOptions wubi_options;
|
||
wubi_options.mixed_pinyin = mixed_pinyin;
|
||
|
||
metasequoia::InputSession v_mode_wubi(SchemeType::Wubi);
|
||
v_mode_wubi.set_wubi_input_options(wubi_options);
|
||
v_mode_wubi.set_pinyin_sequence("V1+2");
|
||
v_mode_wubi.set_pinyin_sequence_with_cases("V1+2");
|
||
v_mode_wubi.set_wubi_literal_input(true);
|
||
v_mode_wubi.recompute_candidates();
|
||
require(v_mode_wubi.get_pinyin_sequence_with_cases() == "V1+2" && v_mode_wubi.get_pinyin_sequence() == "v",
|
||
"Wubi did not keep the raw-case V-mode prefix apart from the query code.");
|
||
|
||
// 不标字面输入时同一串就是普通码:数字不是码字,回读里也不能有。
|
||
metasequoia::InputSession v_code_wubi(SchemeType::Wubi);
|
||
v_code_wubi.set_wubi_input_options(wubi_options);
|
||
v_code_wubi.set_pinyin_sequence("V1+2");
|
||
v_code_wubi.set_pinyin_sequence_with_cases("V1+2");
|
||
v_code_wubi.recompute_candidates();
|
||
require(v_code_wubi.get_pinyin_sequence_with_cases() == "V" && v_code_wubi.get_pinyin_sequence() == "v",
|
||
"A wubi code kept characters that are not code letters in its raw-case string.");
|
||
|
||
metasequoia::InputSession t_mode_wubi(SchemeType::Wubi);
|
||
t_mode_wubi.set_wubi_input_options(wubi_options);
|
||
t_mode_wubi.set_pinyin_sequence("T2024/12/25");
|
||
t_mode_wubi.set_pinyin_sequence_with_cases("T2024/12/25");
|
||
t_mode_wubi.set_wubi_literal_input(true);
|
||
t_mode_wubi.recompute_candidates();
|
||
require(t_mode_wubi.get_pinyin_sequence_with_cases() == "T2024/12/25" &&
|
||
t_mode_wubi.get_pinyin_sequence() == "t" && t_mode_wubi.preedit() == "t",
|
||
"Wubi did not keep the raw-case date/time prefix apart from the query code.");
|
||
|
||
// 反向保护:普通小写码字两个 getter 逐字节相同,预编辑也照旧。
|
||
metasequoia::InputSession plain_wubi(SchemeType::Wubi);
|
||
plain_wubi.set_wubi_input_options(wubi_options);
|
||
plain_wubi.set_pinyin_sequence("wqaa");
|
||
plain_wubi.set_pinyin_sequence_with_cases("wqaa");
|
||
plain_wubi.recompute_candidates();
|
||
require(plain_wubi.get_pinyin_sequence_with_cases() == "wqaa" && plain_wubi.get_pinyin_sequence() == "wqaa" &&
|
||
plain_wubi.preedit() == "wqaa",
|
||
"A plain wubi code no longer round-trips unchanged.");
|
||
|
||
// z 不是码字时(混输与通配都关)必须从原样串里也丢掉:Server 按原样串长度数四码,
|
||
// 留着它会比码多一格,顶字提前一个键触发。混输开启时 z 是合法码字,照旧保留。
|
||
metasequoia::InputSession z_wubi(SchemeType::Wubi);
|
||
z_wubi.set_wubi_input_options(wubi_options);
|
||
z_wubi.set_pinyin_sequence("wqaz");
|
||
z_wubi.set_pinyin_sequence_with_cases("wqaz");
|
||
z_wubi.recompute_candidates();
|
||
require(z_wubi.get_pinyin_sequence_with_cases() == (mixed_pinyin ? "wqaz" : "wqa") &&
|
||
z_wubi.get_pinyin_sequence() == (mixed_pinyin ? "wqaz" : "wqa"),
|
||
"A dropped wubi z did not stay dropped in the raw-case string.");
|
||
}
|
||
|
||
const std::filesystem::path quick_phrase_directory = data_directory / "quick-phrase";
|
||
std::filesystem::create_directories(quick_phrase_directory);
|
||
{
|
||
Database database(quick_phrase_directory / "msime.db");
|
||
database.execute("CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER)");
|
||
database.execute("INSERT INTO quick_parases VALUES('ab','快捷短语一',20)");
|
||
database.execute("INSERT INTO quick_parases VALUES('aa','快捷短语二',10)");
|
||
}
|
||
set_data_directory(quick_phrase_directory);
|
||
metasequoia::InputSession quick_phrase_session(SchemeType::Quanpin);
|
||
require(quick_phrase_session.handle_character('K', true).handled &&
|
||
quick_phrase_session.local_input_mode() == metasequoia::LocalInputMode::QuickPhrase &&
|
||
quick_phrase_session.preedit() == "K" && quick_phrase_session.candidates().empty(),
|
||
"Shift+K did not enter an empty quick-phrase composition.");
|
||
const auto quick_phrase_query = quick_phrase_session.handle_character('a');
|
||
require(quick_phrase_query.handled && !quick_phrase_query.diagnostic.has_value() &&
|
||
quick_phrase_session.preedit() == "Ka" && quick_phrase_session.candidates().size() == 2 &&
|
||
quick_phrase_session.candidates().front().word == "快捷短语一" &&
|
||
quick_phrase_session.candidates().front().source == CandidateSource::QuickPhrase,
|
||
"Quick-phrase mode did not expose prefix candidates.");
|
||
const auto quick_phrase_commit = quick_phrase_session.select_candidate(1);
|
||
require(quick_phrase_commit.handled && quick_phrase_commit.commit == "快捷短语二" &&
|
||
quick_phrase_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Committing a quick phrase did not leave the local mode.");
|
||
|
||
require(quick_phrase_session.handle_character('K', true).handled,
|
||
"Quick-phrase mode could not be re-entered for invalid-input coverage.");
|
||
const std::string quick_phrase_prefix = quick_phrase_session.preedit();
|
||
require(quick_phrase_session.handle_character('1').handled && quick_phrase_session.preedit() == quick_phrase_prefix,
|
||
"Invalid quick-phrase input leaked into normal composition or changed preedit.");
|
||
require(quick_phrase_session.handle_command(metasequoia::Command::Backspace).handled &&
|
||
quick_phrase_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Backspace on a bare quick-phrase prefix did not leave the mode.");
|
||
|
||
metasequoia::LocalModeOptions disabled_quick_phrase_options;
|
||
disabled_quick_phrase_options.quick_phrase = false;
|
||
metasequoia::InputSession disabled_quick_phrase(SchemeType::Quanpin);
|
||
disabled_quick_phrase.set_local_mode_options(disabled_quick_phrase_options);
|
||
require(!disabled_quick_phrase.handle_character('K', true).handled && !disabled_quick_phrase.has_composition() &&
|
||
disabled_quick_phrase.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"A disabled quick-phrase shortcut swallowed Shift+K.");
|
||
|
||
const std::filesystem::path missing_quick_phrase_directory = data_directory / "quick-phrase-missing";
|
||
std::filesystem::create_directories(missing_quick_phrase_directory);
|
||
set_data_directory(missing_quick_phrase_directory);
|
||
metasequoia::InputSession missing_quick_phrase(SchemeType::Quanpin);
|
||
require(missing_quick_phrase.handle_character('K', true).handled,
|
||
"Quick-phrase mode could not start with a missing database.");
|
||
const auto missing_quick_phrase_result = missing_quick_phrase.handle_character('a');
|
||
require(missing_quick_phrase_result.handled && missing_quick_phrase_result.diagnostic.has_value() &&
|
||
missing_quick_phrase.candidates().empty(),
|
||
"A missing quick-phrase database did not report a non-blocking diagnostic.");
|
||
|
||
const std::filesystem::path corrupt_quick_phrase_directory = data_directory / "quick-phrase-corrupt";
|
||
std::filesystem::create_directories(corrupt_quick_phrase_directory);
|
||
write_file(corrupt_quick_phrase_directory / "msime.db", "not a sqlite database");
|
||
set_data_directory(corrupt_quick_phrase_directory);
|
||
metasequoia::InputSession corrupt_quick_phrase(SchemeType::Quanpin);
|
||
require(corrupt_quick_phrase.handle_character('K', true).handled,
|
||
"Quick-phrase mode could not start with a corrupt database.");
|
||
const auto corrupt_quick_phrase_result = corrupt_quick_phrase.handle_character('a');
|
||
require(corrupt_quick_phrase_result.handled && corrupt_quick_phrase_result.diagnostic.has_value() &&
|
||
corrupt_quick_phrase.candidates().empty(),
|
||
"A corrupt quick-phrase database did not report a non-blocking diagnostic.");
|
||
|
||
const std::filesystem::path expressive_directory = data_directory / "expressive-modes";
|
||
std::filesystem::create_directories(expressive_directory);
|
||
{
|
||
Database database(expressive_directory / "others.db");
|
||
database.execute("CREATE TABLE emoji_pinyin(key TEXT,emoji TEXT,sort_order INTEGER)");
|
||
database.execute("INSERT INTO emoji_pinyin VALUES('xiaolian','😀',10)");
|
||
database.execute("INSERT INTO emoji_pinyin VALUES('xiao''lian','😄',20)");
|
||
database.execute("CREATE TABLE kaomoji(pinyin TEXT,jianpin TEXT,kaomoji TEXT,sort_order INTEGER)");
|
||
database.execute("INSERT INTO kaomoji VALUES('haixiu','hx','(*/ω\*)',10)");
|
||
}
|
||
set_data_directory(expressive_directory);
|
||
|
||
metasequoia::InputSession emoji_session(SchemeType::Quanpin);
|
||
require(emoji_session.handle_character('E', true).handled &&
|
||
emoji_session.local_input_mode() == metasequoia::LocalInputMode::Emoji &&
|
||
emoji_session.preedit() == "E" && emoji_session.candidates().empty(),
|
||
"Shift+E did not enter an empty Emoji composition.");
|
||
type(emoji_session, "XIAOLIAN");
|
||
require(emoji_session.preedit() == "EXIAOLIAN" && emoji_session.candidates().size() == 1 &&
|
||
emoji_session.candidates().front().word == "😀" &&
|
||
emoji_session.candidates().front().source == CandidateSource::Emoji,
|
||
"Emoji mode did not accept uppercase input or expose its candidate.");
|
||
const auto emoji_commit = emoji_session.handle_command(metasequoia::Command::CommitCandidate);
|
||
require(emoji_commit.handled && emoji_commit.commit == "😀" &&
|
||
emoji_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Committing an Emoji candidate did not leave Emoji mode.");
|
||
|
||
require(emoji_session.handle_character('E', true).handled,
|
||
"Emoji mode could not be re-entered for apostrophe coverage.");
|
||
type(emoji_session, "xiao'lian");
|
||
require(emoji_session.preedit() == "Exiao'lian" && emoji_session.candidates().size() == 1 &&
|
||
emoji_session.candidates().front().word == "😄",
|
||
"Emoji mode did not retain and query an apostrophe.");
|
||
const std::string emoji_preedit = emoji_session.preedit();
|
||
require(emoji_session.handle_character('1').handled && emoji_session.preedit() == emoji_preedit,
|
||
"Invalid Emoji input changed the local composition.");
|
||
require(emoji_session.handle_command(metasequoia::Command::Backspace).handled &&
|
||
emoji_session.preedit() == "Exiao'lia",
|
||
"Emoji-mode Backspace did not edit and refresh the composition.");
|
||
require(emoji_session.handle_command(metasequoia::Command::Cancel).handled &&
|
||
emoji_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Cancel did not leave Emoji mode.");
|
||
|
||
metasequoia::InputSession kaomoji_session(SchemeType::Shuangpin);
|
||
require(kaomoji_session.handle_character('M', true).handled &&
|
||
kaomoji_session.local_input_mode() == metasequoia::LocalInputMode::Kaomoji,
|
||
"Shift+M did not enter kaomoji mode in Shuangpin.");
|
||
type(kaomoji_session, "hx");
|
||
require(kaomoji_session.candidates().size() == 1 && kaomoji_session.candidates().front().word == "(*/ω\*)" &&
|
||
kaomoji_session.candidates().front().source == CandidateSource::Kaomoji,
|
||
"Kaomoji mode did not expose its Shuangpin-expanded candidate.");
|
||
const auto kaomoji_commit = kaomoji_session.select_candidate(0);
|
||
require(kaomoji_commit.handled && kaomoji_commit.commit == "(*/ω\*)" &&
|
||
kaomoji_session.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Committing a kaomoji did not leave kaomoji mode.");
|
||
|
||
metasequoia::LocalModeOptions disabled_expressive_options;
|
||
disabled_expressive_options.emoji = false;
|
||
disabled_expressive_options.kaomoji = false;
|
||
metasequoia::InputSession disabled_expressive(SchemeType::Quanpin);
|
||
disabled_expressive.set_local_mode_options(disabled_expressive_options);
|
||
require(!disabled_expressive.handle_character('E', true).handled && !disabled_expressive.has_composition() &&
|
||
disabled_expressive.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"A disabled Emoji shortcut swallowed Shift+E.");
|
||
require(!disabled_expressive.handle_character('M', true).handled && !disabled_expressive.has_composition() &&
|
||
disabled_expressive.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"A disabled kaomoji shortcut swallowed Shift+M.");
|
||
|
||
metasequoia::InputSession disabling_active_expressive(SchemeType::Quanpin);
|
||
require(disabling_active_expressive.handle_character('E', true).handled &&
|
||
disabling_active_expressive.local_input_mode() == metasequoia::LocalInputMode::Emoji,
|
||
"Emoji mode could not start before option-reset coverage.");
|
||
auto disable_active_options = disabling_active_expressive.local_mode_options();
|
||
disable_active_options.emoji = false;
|
||
disabling_active_expressive.set_local_mode_options(disable_active_options);
|
||
require(!disabling_active_expressive.has_composition() &&
|
||
disabling_active_expressive.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Disabling an active Emoji mode did not reset it.");
|
||
|
||
metasequoia::InputSession wubi_expressive(SchemeType::Wubi);
|
||
require(!wubi_expressive.handle_character('E', true).handled && !wubi_expressive.has_composition() &&
|
||
wubi_expressive.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Shift+E was swallowed outside a pinyin scheme.");
|
||
require(!wubi_expressive.handle_character('M', true).handled && !wubi_expressive.has_composition() &&
|
||
wubi_expressive.local_input_mode() == metasequoia::LocalInputMode::None,
|
||
"Shift+M was swallowed outside a pinyin scheme.");
|
||
|
||
const std::filesystem::path missing_expressive_directory = data_directory / "expressive-missing";
|
||
std::filesystem::create_directories(missing_expressive_directory);
|
||
set_data_directory(missing_expressive_directory);
|
||
metasequoia::InputSession missing_emoji_session(SchemeType::Quanpin);
|
||
require(missing_emoji_session.handle_character('E', true).handled,
|
||
"Emoji mode could not start with a missing database.");
|
||
const auto missing_emoji_result = missing_emoji_session.handle_character('x');
|
||
require(missing_emoji_result.handled && missing_emoji_result.diagnostic.has_value() &&
|
||
missing_emoji_session.candidates().empty(),
|
||
"A missing Emoji database did not report a non-blocking diagnostic.");
|
||
#endif
|
||
|
||
return 0;
|
||
}
|
||
|
||
int main()
|
||
{
|
||
try
|
||
{
|
||
return run_test();
|
||
}
|
||
catch (const std::exception &exception)
|
||
{
|
||
std::fprintf(stderr, "%s\n", exception.what());
|
||
return 1;
|
||
}
|
||
catch (...)
|
||
{
|
||
std::fprintf(stderr, "An exception escaped the test body.\n");
|
||
return 1;
|
||
}
|
||
}
|