Files
msime-windows/engine/tests/src/test_input_session.cpp
T
fanlusky cf07b77df6 feat(engine): 等音节数字面切分按整句打分裁决,预编辑跟随首选
最少音节切分平局时固定取较短首音节,切分又在查词之前定死,yibananzhao 只会切成
yi'ba'nan'zhao 出「一把难找」,「一般按照」「这个方案很好」这类 n/ng 边界歧义的整句没有机会。

- arbitrate_literal_segmentations:合法输入的等音节数字面切分各解一遍词格,路径分最高的领衔,
  同分仍取原切分;输方分差在 3.0 以内时它的整句占第 2 位,更远不出。任一切分在词库里有整词时
  不介入(单词级仍按词频),整句来源全关时与原来一致,用户调过频的读音不让出领衔
- 合法输入纠错的原读法改为领衔的字面切分:纠错胜出时第 2 位是得分最高的字面读法
- 首选是另一种等音节数字面切分时预编辑跟着它分(yi'ban'an'zhao、fang'an)
- 胜出切分的前缀词替换原切分前缀区间扫出的同名行,选「一般」按 yi'ban 消耗
- 词格跨度查询与最佳路径加缓存,随 reset_cache 清空;普通输入每键 p90 也随之下降
- 新增 eval_quanpin_sentence 与句子级评测集 tests/data/quanpin_sentence_cases.tsv
2026-10-12 03:05:44 +08:00

3084 lines
186 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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 "../../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);");
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;
// 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.");
}
// 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();
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、jiuzheeyang 切成
// jiu'zhe'e'yang,每一段都合法,纠错入口的闸原本把它们整个挡在外面。现在按噪声信道
// 整句打分:词格路径分 - 每处纠正的代价(换位 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("jiuzheeyang", {"jiu", "zhe", "e", "yang"}, quanpin::kAutocorrectInsertion), "jiu'zhe'yang"),
"An extra 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.");
}
// 多打一个 e(改变字母数,代价 3.0):就这样 = ln5000+9-3 = 14.5,就这恶养全是单字
// = -2.2。字面切分不是真词,
// 纠错领衔,原读法紧跟在第 2 位;预编辑按纠错切分的原始区间分隔,整词上屏吃掉全部字母。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type(session, "jiuzheeyang");
require(first_two(session, "就这样", "就"),
"jiuzheeyang must lead with 就这样 and keep the uncorrected reading second.");
require(session.get_pinyin_segmentation_with_cases() == "jiu'zhee'yang",
"The preedit must follow the raw spans of the leading insertion correction.");
const auto committed = session.select_candidate(static_cast<std::size_t>(0));
require(committed.commit == "就这样" && !session.has_composition(),
"Selecting the insertion-corrected sentence 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);
}
// 贵档读法在上下文明显更好时可以领衔:shiideya 的换位读法 shi + ide->die(权重 10,
// 是爹呀)比多字读法 shii->shi(权重 12,是的呀)便宜一档,按旧规则贵档永远排在最后。
// fixture 无 sc.lm,启发式打分:是的(ln10000+6) + 呀(ln0.5) - 2.4 = 14.1,
// 是 + 爹 + 呀 全是单字 = ln0.9 + ln0.01 + ln0.5 - 2.0 = -7.4。
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('shi', 's', '是', 900000);");
database.execute("CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_2_s VALUES('shi''de', 'sd', '是的', 10000);");
database.execute("CREATE TABLE tbl_1_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_d VALUES('die', 'd', '爹', 10000);"
"INSERT INTO tbl_1_d VALUES('de', 'd', '的', 900000);");
database.execute("CREATE TABLE tbl_1_y(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_y VALUES('ya', 'y', '呀', 500000);");
}
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, "shiideya");
require(!session.candidates().empty() && session.candidates().front().word == "是的呀",
"A costlier correction the context clearly prefers must lead.");
require(find_candidate_index(session, "是爹呀") < session.candidates().size(),
"The cheaper correction must stay available.");
require(session.get_pinyin_segmentation_with_cases() == "shii'de'ya",
"The preedit must follow the leading costlier correction.");
}
// 只开万象重排、不显示 Trigram 整句(用户实际配置):上下文消解照样生效。fixture
// 没有 .gram,重排不出整句行,贵档胜者由它的最长前缀词「是的」领衔。
{
SentenceAssociationOptions rerank_only;
rerank_only.collocation_rerank = true;
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
session.set_sentence_association(rerank_only);
type(session, "shiideya");
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);
}
// 合法输入的等音节数字面切分按整句打分争领衔(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 非邻键,远键替换)
// - zthou = zhou 的 z/h 之间插 t(t 不在静态覆盖集,远键插入)
// - 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_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_z VALUES('zhou', 'z', '周', 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);");
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);");
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.");
}
// 远键插入(词中位):zthou → zhou(t 不在 z/h 的覆盖集)。fixture 特意
// 选无竞争切分的输入:zhwou 这类插入位会拼出 [zha(w 邻键替换)+ou] 等更
// 便宜的合法切分(权重 13 < 15),生成读法按契约排后被前缀候选遮蔽——
// 那是排序语义,不是生成类失效。zthou 无任何竞争切分,唯一读即纠错读法。
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type(session, "zthou");
const auto found = find_candidate_index(session, "周");
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "zthou",
"A far-insertion typo must be corrected to 周 with the typed letters recorded.");
}
// 静态优先语义: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)与 shatng→shang(生成插入 15)。丢弃规则由 k-best 搜索自身执行,head
// 路径同享:静态读法的沙汤扎(sha'tang'zh 精确键)在场,上周(shang+zh 生成
// 读法经 costlier 档,表内输入的基线里不存在)不得出现。
{
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.");
}
// 守卫与合法输入:尾位插入(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.");
}
}
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 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");
}
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");
}
// 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.candidates().empty() && session.prefix_end() == 0 && session.pending_suffix() == "nihkb;",
"A caret inside the first shuangpin unit must quantize to an empty prefix");
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.");
}
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;
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.");
}
// 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 fall back to the input as typed when
// the alias layer rewrote the letters: no phantom letter, and a manual
// delimiter the user typed stays in place.
const std::array<PreeditCase, 4> typed_fallback_preedit_cases = {
{{"dongua", "dongua"}, {"zhonguo", "zhonguo"}, {"haoyonga'", "haoyonga'"}, {"hao'yonga", "hao'yonga"}}};
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 alias-rewritten input must preedit exactly what was typed.");
}
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.");
}
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_generated_space_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
metasequoia::InputSession unicode_session(SchemeType::Quanpin);
require(unicode_session.handle_character('U', true).handled &&
unicode_session.local_input_mode() == metasequoia::LocalInputMode::Unicode &&
unicode_session.preedit() == "U" && unicode_session.candidates().empty(),
"Shift+U did not enter an empty Unicode composition.");
require(unicode_session.handle_character('g').handled && unicode_session.preedit() == "U" &&
unicode_session.candidates().empty(),
"Unicode mode accepted or forwarded a non-hexadecimal character.");
for (const char character : std::string("4e00"))
{
require(unicode_session.handle_character(character).handled, "Unicode mode rejected a hexadecimal character.");
}
require(unicode_session.preedit() == "U4e00" && unicode_session.candidates().size() == 1 &&
unicode_session.candidates().front().word == "一" &&
unicode_session.candidates().front().pinyin == "U+4E00" &&
unicode_session.candidates().front().source == CandidateSource::Generated,
"Unicode mode did not produce the Windows-compatible BMP candidate.");
const auto unicode_commit = unicode_session.select_candidate(0);
require(unicode_commit.handled && unicode_commit.commit == "一" && !unicode_session.has_composition() &&
unicode_session.local_input_mode() == metasequoia::LocalInputMode::None,
"Committing a Unicode candidate did not leave the local mode.");
// 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(unicode_session.handle_character('U', true).handled,
"Unicode mode could not be re-entered for the boundary check.");
require(unicode_session.segment_raw_boundaries().empty(),
"A local mode must report no pinyin segment boundaries.");
require(unicode_session.handle_command(metasequoia::Command::Cancel).handled,
"Cancel did not leave Unicode 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(unicode_session.handle_character('U', true).handled && unicode_session.handle_character('+').handled,
"Unicode mode rejected its optional plus prefix.");
for (const char character : std::string("1f600"))
{
require(unicode_session.handle_character(character).handled,
"Unicode mode rejected a supplementary-plane hexadecimal character.");
}
require(unicode_session.preedit() == "U+1f600" && unicode_session.candidates().size() == 1 &&
unicode_session.candidates().front().word == "😀",
"Unicode mode did not produce a supplementary-plane scalar.");
require(unicode_session.handle_command(metasequoia::Command::Cancel).handled && !unicode_session.has_composition(),
"Cancel did not leave Unicode mode.");
const auto require_invalid_unicode = [&](const std::string &hex) {
require(unicode_session.handle_character('U', true).handled,
"Unicode mode could not be re-entered for invalid-scalar coverage.");
for (const char character : hex)
{
require(unicode_session.handle_character(character).handled,
"Unicode mode rejected an invalid scalar's hexadecimal spelling.");
}
require(unicode_session.candidates().empty(), "Unicode mode produced an invalid scalar candidate.");
require(unicode_session.handle_command(metasequoia::Command::Cancel).handled,
"Unicode invalid-scalar fixture could not be cancelled.");
};
require_invalid_unicode("d800");
require_invalid_unicode("110000");
require_invalid_unicode("0000001");
require(unicode_session.handle_character('U', true).handled,
"Unicode prefix was not handled before Backspace coverage.");
require(unicode_session.handle_command(metasequoia::Command::Backspace).handled &&
!unicode_session.has_composition() &&
unicode_session.local_input_mode() == metasequoia::LocalInputMode::None,
"Backspace on a bare Unicode 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.unicode = false;
metasequoia::InputSession disabled_unicode(SchemeType::Quanpin);
disabled_unicode.set_local_mode_options(disabled_local_modes);
require(!disabled_unicode.handle_character('U', true).handled && !disabled_unicode.has_composition() &&
disabled_unicode.local_input_mode() == metasequoia::LocalInputMode::None,
"A disabled Unicode shortcut swallowed Shift+U.");
metasequoia::InputSession wubi_unicode(SchemeType::Wubi);
require(!wubi_unicode.handle_character('U', true).handled && !wubi_unicode.has_composition() &&
wubi_unicode.local_input_mode() == metasequoia::LocalInputMode::None,
"Shift+U was swallowed outside a pinyin scheme.");
metasequoia::InputSession switch_clears_unicode(SchemeType::Shuangpin);
require(switch_clears_unicode.handle_character('U', true).handled &&
switch_clears_unicode.local_input_mode() == metasequoia::LocalInputMode::Unicode,
"Shuangpin could not enter Unicode mode.");
switch_clears_unicode.switch_scheme(SchemeType::Quanpin);
require(!switch_clears_unicode.has_composition() &&
switch_clears_unicode.local_input_mode() == metasequoia::LocalInputMode::None,
"Switching schemes did not clear Unicode 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.");
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;
}
}