Files
msime-windows/engine/tests/src/test_pinyin.cpp
T
fanlusky 49d0bf58b5 fix(engine): 全拼纠错支持"纠正头部 + 简拼尾巴"的组合
问题:autocorrect_cut_kbest 只有在整个输入都能切成完整音节时才成功。
所以 hauzh(hau 打错 + zh 简拼)、hauz 这类"需纠正的头部 + 不完整音节
尾巴"整体切分失败,既不纠错也查不出词;而 huazh(hua 本就合法,zh 走
简拼)和 hauzhong(hua+zhong 都是完整音节)都正常。

改动:resolve_series_query 在整串纠错失败时,尝试剥掉结尾的简拼声母
(不完整音节:是合法拼音前缀但本身不是完整音节,如 zh/z/h),对头部
纠错,成功后把纠正结果与尾巴拼成 hua'zh 交给既有的简拼查询路径处理。
优先尝试更短的尾巴,让纠错尽量解释更多输入。复用同一套代价分层逻辑,
更贵的头部读音(如 hau→hai)仍排在便宜的(hau→hua)之后。

头部纠错排除邻键(neighbor)类型:邻键表最大、误报面最广,而本路径已经
放宽了"整串must是合法切分"的约束、去信任一个推测出来的简拼边界,两者
叠加会把漏字形状的输入变成噪声(shng → sun + g → 笋干,而 shng 在关闭
deletion 时本应不纠错)。换位/漏字/多字是结构性错拼,置信度足够,可组合。

效果:hauzh→hua'zh(化妆)、hauz→hua'z、gauzh→gua'zh(挂着)、
hauzhong→hua'zhong(华中)均可纠;huazh/nihaoa 等正常输入不受影响,
关闭纠错时也不组合;shng 关闭 deletion 时不再被组合成 sun'g。

验证:imetest 全绿(新增组合段位断言、关闭开关守卫、shng 不组合守卫);
server 集成测试全绿(QuanpinDictionaryAutocorrectDeletionTypo 等,0 失败);
eval(seed 42,300 样本)mixed 与 deletion 两模型逐档与基线一致;
scripts/format.sh --check 通过。
2026-09-13 16:25:32 +08:00

1461 lines
79 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 <Windows.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <iomanip>
#include <sstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <fmt/core.h>
#include "fmt/base.h"
#include "core/ime_session.h"
#include "quanpin/autocorrect_table.h"
#include "quanpin/quanpin_dictionary.h"
#include "quanpin/quanpin_utils.h"
#include "quanpin/word_lattice.h"
#include "quanpin/quanpin_query.h"
#include "sqlite3.h"
#include "shuangpin/shuangpin_dictionary.h"
#include "shuangpin/shuangpin_query.h"
#include "shuangpin/shuangpin_utils.h"
#include "core/data_path.h"
#include "core/input_session.h"
#include "user_dictionary/user_dictionary_journal.h"
#include <algorithm>
#include <fstream>
#include <unordered_set>
using namespace std;
namespace
{
namespace fs = std::filesystem;
class ScopedLocalAppDataOverride
{
public:
explicit ScopedLocalAppDataOverride(const std::string &suffix)
{
const fs::path source_dir = metasequoia::data_directory();
if (source_dir.empty())
{
throw std::runtime_error("A data directory should be available for regression tests.");
}
const auto current = metasequoia::detail::wide_environment_variable(kDataDirectoryVariable);
original_ = current.value_or(L"");
root_ = fs::temp_directory_path() / "msime-regression" / suffix;
app_dir_ = root_ / "metasequoiaime";
fs::remove_all(root_);
fs::create_directories(app_dir_);
// msime.db 是回归断言的主体,缺失即环境不完整;而当前产品布局已不再发布
// 整句解码器的两个数据文件(dict_pinyin.dat/user_dict.dat),引擎对它们
// 的缺失也是优雅降级(PinyinDecoder::sentence 直接返回空串),所以这里
// 仅在源目录存在时才拷贝,不把它们当硬依赖。
for (const auto &file_name : {"msime.db", "dict_pinyin.dat", "user_dict.dat"})
{
const fs::path source = source_dir / file_name;
const fs::path target = app_dir_ / file_name;
if (!fs::exists(source))
{
if (file_name == std::string_view("msime.db"))
{
throw std::runtime_error(fmt::format("Expected test dependency '{}' to exist.", source.string()));
}
continue;
}
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
}
if (_wputenv_s(kDataDirectoryVariable, app_dir_.c_str()) != 0)
{
throw std::runtime_error("Failed to override the data directory for regression test.");
}
}
~ScopedLocalAppDataOverride()
{
(void)_wputenv_s(kDataDirectoryVariable, original_.c_str());
std::error_code ec;
fs::remove_all(root_, ec);
}
std::string local_appdata() const
{
return root_.string();
}
private:
static constexpr const wchar_t *kDataDirectoryVariable = L"METASEQUOIA_IME_DATA_DIR";
std::wstring original_;
fs::path root_;
fs::path app_dir_;
};
void expect(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}
void expect_session_state(const ImeSession &session, const std::string &expected_preedit)
{
expect(session.get_preedit() == expected_preedit,
fmt::format("Expected preedit '{}', got '{}'", expected_preedit, session.get_preedit()));
expect(session.get_request().raw_input == expected_preedit,
fmt::format("Expected raw_input '{}', got '{}'", expected_preedit, session.get_request().raw_input));
}
} // namespace
std::string hex_dump(const std::string &text)
{
std::ostringstream oss;
oss << std::hex << std::setfill('0');
for (size_t i = 0; i < text.size(); ++i)
{
if (i > 0)
{
oss << ' ';
}
oss << std::setw(2) << static_cast<int>(static_cast<unsigned char>(text[i]));
}
return oss.str();
}
void print_candidates(const std::vector<WordItem> &result)
{
for (size_t index = 0; index < result.size(); ++index)
{
const auto &item = result[index];
const auto &code = item.pinyin;
const auto &word = item.word;
const auto weight = item.weight;
try
{
fmt::println("Candidate #{}: {} [{}] ({})", index, word, code, weight);
}
catch (const std::exception &ex)
{
throw std::runtime_error(
fmt::format("Failed to print candidate #{}; word bytes=[{}], code bytes=[{}], error={}", index,
hex_dump(word), hex_dump(code), ex.what()));
}
}
}
const WordItem *find_candidate(const std::vector<WordItem> &result, const std::string &word)
{
const auto found =
std::find_if(result.begin(), result.end(), [&](const WordItem &item) { return item.word == word; });
return found == result.end() ? nullptr : &(*found);
}
void run_quanpin_query_case(QuanpinDictionary &dictionary, const std::string &query)
{
const auto start = std::chrono::high_resolution_clock::now();
const auto result = dictionary.query(query);
const auto end = std::chrono::high_resolution_clock::now();
const auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
fmt::println("Query: {}", query);
fmt::println("Time: {} us", duration.count());
print_candidates(result);
}
void feed_sequence(ImeSession &session, const vector<UINT> &sequence, const vector<WCHAR> &wch_sequence = {})
{
for (int i = 0; i < sequence.size(); ++i)
{
std::chrono::high_resolution_clock::time_point start = std::chrono::high_resolution_clock::now();
session.handle_key(sequence[i], 0, i < wch_sequence.size() ? wch_sequence[i] : 0);
std::chrono::high_resolution_clock::time_point end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
fmt::println("Preedit: {}", session.get_preedit());
fmt::println("Time: {} us", duration.count());
}
}
void test_shuangpin_session()
{
ImeSession session(SchemeType::Shuangpin);
const vector<UINT> sequence{'C', 'L', 'S'}; // 按键的 vk 码
const vector<WCHAR> wch_sequence{'c', 'l', 's'}; // 实际的字符,区分大小写输入
fmt::println("==== Shuangpin ====");
feed_sequence(session, sequence, wch_sequence);
print_candidates(session.get_candidates());
}
void test_shuangpin_session02()
{
// ImeSession session(SchemeType::Quanpin);
ImeSession session(SchemeType::Shuangpin);
// const vector<UINT> sequence{'C', 'E', 'L', 'I', 'S', 'H', 'I'};
const vector<UINT> sequence{'C', 'E', 'L', 'I', 'U', 'I'};
const vector<WCHAR> wch_sequence{'c', 'e', 'l', 'i', 'u', 'i'};
fmt::println("==== Shuangpin ====");
feed_sequence(session, sequence, wch_sequence);
print_candidates(session.get_candidates());
}
void test_quanpin_session()
{
ImeSession session(SchemeType::Quanpin);
const vector<UINT> sequence{'C', 'E', 'S', 'H', 'I'};
const vector<WCHAR> wch_sequence{'c', 'e', 's', 'h', 'i'};
fmt::println("==== Quanpin ====");
feed_sequence(session, sequence, wch_sequence);
print_candidates(session.get_candidates());
}
void test_dynamic_switch()
{
ImeSession session(SchemeType::Shuangpin);
fmt::println("==== Switch Scheme ====");
feed_sequence(session, {'N', 'I'}, {'n', 'i'});
fmt::println("Before switch preedit: {}", session.get_preedit());
session.switch_scheme(SchemeType::Quanpin);
fmt::println("After switch preedit: {}", session.get_preedit());
feed_sequence(session, {'N', 'I', 'H', 'A', 'O'}, {'n', 'i', 'h', 'a', 'o'});
print_candidates(session.get_candidates());
}
void test_quanpin_session_backspace()
{
ImeSession session(SchemeType::Quanpin);
fmt::println("==== Quanpin Backspace ====");
feed_sequence(session, {'C', 'E', 'S', 'H', 'I'}, {'c', 'e', 's', 'h', 'i'});
expect_session_state(session, "ceshi");
expect(!session.get_candidates().empty(), "Quanpin session should have candidates before backspace.");
session.handle_key(VK_BACK);
fmt::println("Preedit after backspace: {}", session.get_preedit());
expect_session_state(session, "cesh");
expect(session.get_request().valid, "Quanpin session request should stay valid after backspace.");
}
void test_shuangpin_session_backspace()
{
ImeSession session(SchemeType::Shuangpin);
fmt::println("==== Shuangpin Backspace ====");
feed_sequence(session, {'C', 'E', 'L', 'I', 'U', 'I'}, {'c', 'e', 'l', 'i', 'u', 'i'});
expect_session_state(session, "celiui");
expect(!session.get_candidates().empty(), "Shuangpin session should have candidates before backspace.");
session.handle_key(VK_BACK);
fmt::println("Preedit after backspace: {}", session.get_preedit());
expect_session_state(session, "celiu");
expect(session.get_request().valid, "Shuangpin session request should stay valid after backspace.");
}
void test_shuangpin_manual_apostrophe()
{
ImeSession session(SchemeType::Shuangpin);
fmt::println("==== Shuangpin Manual Apostrophe ====");
feed_sequence(session, {'J', 'W', VK_OEM_7, 'D'}, {'j', 'w', '\'', 'd'});
expect_session_state(session, "jw'd");
expect(session.get_request().raw_segmentation.find('\'') != std::string::npos,
fmt::format("Expected raw segmentation to preserve apostrophes, got '{}'",
session.get_request().raw_segmentation));
expect(session.get_request().normalized_segmentation.find('\'') != std::string::npos,
fmt::format("Expected normalized segmentation to preserve apostrophes, got '{}'",
session.get_request().normalized_segmentation));
expect(session.get_request().normalized_input.find('\'') == std::string::npos,
fmt::format("Expected normalized input to strip apostrophes, got '{}'",
session.get_request().normalized_input));
session.handle_key(VK_BACK);
expect_session_state(session, "jw'");
session.handle_key(VK_BACK);
expect_session_state(session, "jw");
}
void test_shuangpin_query_manual_apostrophe()
{
fmt::println("==== Shuangpin Query Manual Apostrophe ====");
expect(shuangpin::segment_input("ce'ce") == "ce'ce",
"Expected manual apostrophe to be preserved in raw segmentation for complete chunks.");
expect(shuangpin::normalize_input_with_delimiters("ce'ce") == "ce'ce",
"Expected manual apostrophe to be preserved in normalized segmentation for complete chunks.");
expect(shuangpin::normalize_input("ce'ce") == "cece",
"Expected normalized input to strip apostrophes for complete chunks.");
expect(shuangpin::is_complete_input("ce'ce"), "Expected ce'ce to be recognized as complete shuangpin input.");
expect(!shuangpin::is_complete_input("jw'"), "Expected trailing manual apostrophe to stay incomplete.");
}
void test_quanpin_dictionary_backspace()
{
QuanpinDictionary dictionary;
fmt::println("==== Quanpin Dictionary Backspace ====");
dictionary.handleVkCode('C', 0, 'c');
dictionary.handleVkCode('E', 0, 'e');
dictionary.handleVkCode('S', 0, 's');
expect(dictionary.get_pinyin_sequence() == "ces",
fmt::format("Expected quanpin dictionary sequence 'ces', got '{}'", dictionary.get_pinyin_sequence()));
dictionary.handleVkCode(VK_BACK, 0);
expect(dictionary.get_pinyin_sequence() == "ce",
fmt::format("Expected quanpin dictionary sequence 'ce' after backspace, got '{}'",
dictionary.get_pinyin_sequence()));
expect(!dictionary.get_current_candidate_list().empty(),
"Quanpin dictionary should still have candidates after backspace.");
}
void test_shuangpin_dictionary_backspace()
{
ShuangpinDictionary dictionary;
fmt::println("==== Shuangpin Dictionary Backspace ====");
dictionary.handleVkCode('C', 0, 'c');
dictionary.handleVkCode('E', 0, 'e');
dictionary.handleVkCode('L', 0, 'l');
expect(dictionary.get_pinyin_sequence() == "cel",
fmt::format("Expected shuangpin dictionary sequence 'cel', got '{}'", dictionary.get_pinyin_sequence()));
dictionary.handleVkCode(VK_BACK, 0);
expect(dictionary.get_pinyin_sequence() == "ce",
fmt::format("Expected shuangpin dictionary sequence 'ce' after backspace, got '{}'",
dictionary.get_pinyin_sequence()));
expect(!dictionary.get_current_candidate_list().empty(),
"Shuangpin dictionary should still have candidates after backspace.");
}
void test_shuangpin_dictionary_create_pin_delete()
{
ScopedLocalAppDataOverride local_appdata("shuangpin-write-regression");
expect(ShuangpinUtil::get_local_appdata_path() == local_appdata.local_appdata(),
fmt::format("Expected shuangpin appdata path '{}', got '{}'.", local_appdata.local_appdata(),
ShuangpinUtil::get_local_appdata_path()));
const std::string expected_user_db = local_appdata.local_appdata() + "\\metasequoiaime\\msime_user.db";
expect(user_dictionary::default_user_db_path() == expected_user_db,
fmt::format("Expected user db path '{}', got '{}'.", expected_user_db,
user_dictionary::default_user_db_path()));
const char *process_appdata = std::getenv("LOCALAPPDATA");
expect(process_appdata != nullptr && std::string(process_appdata) != local_appdata.local_appdata(),
"Overriding the data root must not touch the process environment.");
sqlite3 *probe_db = nullptr;
const std::string probe_db_path = local_appdata.local_appdata() + "\\metasequoiaime\\msime.db";
expect(sqlite3_open(probe_db_path.c_str(), &probe_db) == SQLITE_OK,
fmt::format("Failed to open probe db '{}'.", probe_db_path));
char *probe_error = nullptr;
expect(sqlite3_exec(probe_db,
"insert into tbl_2_c (key, jp, value, weight) values ('ce''li', 'cl', '测棂', 10000);", nullptr,
nullptr, &probe_error) == SQLITE_OK,
fmt::format("Expected probe insert to succeed, got '{}'.", probe_error == nullptr ? "" : probe_error));
sqlite3_free(probe_error);
probe_error = nullptr;
expect(sqlite3_exec(probe_db, "delete from tbl_2_c where key = 'ce''li' and value = '测棂';", nullptr, nullptr,
&probe_error) == SQLITE_OK,
fmt::format("Expected probe delete to succeed, got '{}'.", probe_error == nullptr ? "" : probe_error));
sqlite3_free(probe_error);
sqlite3_close(probe_db);
ShuangpinDictionary dictionary;
const std::string raw_shuangpin = "celi";
const std::string segmented_shuangpin = shuangpin::segment_input(raw_shuangpin);
const std::string test_word = "测棂";
fmt::println("==== Shuangpin Dictionary Create/Pin/Delete ====");
// Clean up any residue from prior runs so the assertions stay deterministic.
dictionary.delete_by_pinyin_and_word(raw_shuangpin, test_word);
const auto before_create = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
expect(find_candidate(before_create, test_word) == nullptr,
fmt::format("Expected '{}' to be absent before create.", test_word));
expect(dictionary.create_word(raw_shuangpin, test_word) == ShuangpinDictionary::OK,
"Shuangpin create_word should succeed.");
const auto after_create = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
const WordItem *created = find_candidate(after_create, test_word);
expect(created != nullptr, fmt::format("Expected '{}' to appear after create.", test_word));
const int created_weight = created->weight;
const std::string canonical_pinyin = created->canonical_pinyin;
expect(!canonical_pinyin.empty(), "Created candidate should expose its canonical pinyin.");
expect(dictionary.update_weight_by_pinyin_and_word(raw_shuangpin, test_word) == ShuangpinDictionary::OK,
"Shuangpin update_weight_by_pinyin_and_word should succeed.");
const auto after_pin = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
const WordItem *pinned = find_candidate(after_pin, test_word);
expect(pinned != nullptr, fmt::format("Expected '{}' to remain after pin.", test_word));
expect(pinned->weight > created_weight,
fmt::format("Expected pinned weight to increase from {}, got {}.", created_weight, pinned->weight));
expect(dictionary.delete_by_pinyin_and_word(canonical_pinyin, test_word) == ShuangpinDictionary::OK,
"Shuangpin delete_by_pinyin_and_word should accept a canonical pinyin key.");
const auto after_delete = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
expect(find_candidate(after_delete, test_word) == nullptr,
fmt::format("Expected '{}' to be absent after delete.", test_word));
}
void test_shuangpin_dictionary_create_pin_delete_three_syllables()
{
ScopedLocalAppDataOverride local_appdata("shuangpin-write-three-syllables");
ShuangpinDictionary dictionary;
const std::string raw_shuangpin = "qbtmuo";
const std::string segmented_shuangpin = shuangpin::segment_input(raw_shuangpin);
const std::string test_word = "秦天朔";
fmt::println("==== Shuangpin Dictionary Create/Pin/Delete Three Syllables ====");
dictionary.delete_by_pinyin_and_word(raw_shuangpin, test_word);
const auto before_create = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
expect(find_candidate(before_create, test_word) == nullptr,
fmt::format("Expected '{}' to be absent before create.", test_word));
expect(dictionary.create_word(raw_shuangpin, test_word) == ShuangpinDictionary::OK,
"Three-syllable shuangpin create_word should succeed.");
const auto after_create = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
const WordItem *created = find_candidate(after_create, test_word);
expect(created != nullptr, fmt::format("Expected '{}' to appear after create.", test_word));
const int created_weight = created->weight;
expect(dictionary.update_weight_by_pinyin_and_word(raw_shuangpin, test_word) == ShuangpinDictionary::OK,
"Three-syllable shuangpin update_weight_by_pinyin_and_word should succeed.");
const auto after_pin = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
const WordItem *pinned = find_candidate(after_pin, test_word);
expect(pinned != nullptr, fmt::format("Expected '{}' to remain after pin.", test_word));
expect(pinned->weight > created_weight,
fmt::format("Expected pinned weight to increase from {}, got {}.", created_weight, pinned->weight));
expect(dictionary.delete_by_pinyin_and_word(raw_shuangpin, test_word) == ShuangpinDictionary::OK,
"Three-syllable shuangpin delete_by_pinyin_and_word should succeed.");
const auto after_delete = dictionary.generateSeries(raw_shuangpin, segmented_shuangpin);
expect(find_candidate(after_delete, test_word) == nullptr,
fmt::format("Expected '{}' to be absent after delete.", test_word));
}
void test_quanpin_four_syllable_alternative_segmentation()
{
QuanpinDictionary dictionary;
fmt::println("==== Quanpin Four Syllable Alternative Segmentation ====");
// jianmingeyao is cut as jian'min'ge'yao, so the entry only shows up through
// the alternative segmentation jian'ming'e'yao.
const auto result = dictionary.query("jianmingeyao");
const auto *found = find_candidate(result, "简明扼要");
expect(found != nullptr, "Expected '简明扼要' among the candidates for 'jianmingeyao'.");
}
void test_quanpin_single_letter_jianpin_ranking()
{
ScopedLocalAppDataOverride local_appdata("single-letter-jianpin-ranking");
QuanpinDictionary dictionary;
fmt::println("==== Quanpin Single Letter Jianpin Ranking ====");
// A single-letter context mixes entry keys: 一 comes from yi, 有 from you.
const auto before = dictionary.query("y");
const WordItem *selected = find_candidate(before, "有");
const WordItem *rival = find_candidate(before, "一");
expect(selected != nullptr, "Expected '有' among the candidates for 'y'.");
if (rival == nullptr || rival->weight <= selected->weight)
{
fmt::println("Skipped: '一' does not outweigh '有' in this dictionary.");
return;
}
// The server keys a selection by its canonical pinyin, so entry_key is 'you'
// while context_key stays 'y'.
const std::string entry_key = selected->canonical_pinyin.empty() ? selected->pinyin : selected->canonical_pinyin;
bool ranking_changed = false;
expect(user_dictionary::adjust_candidate_ranking(local_appdata.local_appdata() + "\\metasequoiaime\\msime.db",
user_dictionary::default_user_db_path(), "y", before, entry_key,
"有", "promote", 1, 1, true, &ranking_changed),
"Expected the single-letter ranking adjustment to succeed.");
expect(ranking_changed, "Expected the single-letter ranking adjustment to write a weight.");
QuanpinDictionary reloaded;
const auto after = reloaded.query("y");
const WordItem *promoted = find_candidate(after, "有");
const WordItem *demoted = find_candidate(after, "一");
expect(promoted != nullptr, "Expected '有' to survive the ranking adjustment.");
expect(demoted == nullptr || promoted->weight > demoted->weight,
fmt::format("Expected '有' to outweigh '一' under context 'y', got {} and {}.", promoted->weight,
demoted == nullptr ? 0 : demoted->weight));
}
void test_quanpin_query_timings()
{
QuanpinDictionary dictionary;
fmt::println("==== Quanpin Query Timings ====");
run_quanpin_query_case(dictionary, "nih");
run_quanpin_query_case(dictionary, "niha");
run_quanpin_query_case(dictionary, "nihao");
run_quanpin_query_case(dictionary, "ni");
run_quanpin_query_case(dictionary, "n");
run_quanpin_query_case(dictionary, "shen");
run_quanpin_query_case(dictionary, "shenme");
run_quanpin_query_case(dictionary, "shenmeshi");
run_quanpin_query_case(dictionary, "shenmeshi");
run_quanpin_query_case(dictionary, "shenmeshui");
run_quanpin_query_case(dictionary, "shenmesh");
run_quanpin_query_case(dictionary, "shenmes");
run_quanpin_query_case(dictionary, "n");
run_quanpin_query_case(dictionary, "ni");
run_quanpin_query_case(dictionary, "nis");
run_quanpin_query_case(dictionary, "nish");
run_quanpin_query_case(dictionary, "nishu");
run_quanpin_query_case(dictionary, "nishuo");
run_quanpin_query_case(dictionary, "nishuon");
run_quanpin_query_case(dictionary, "nishuone");
run_quanpin_query_case(dictionary, "keneng");
}
quanpin::WordLatticeLookup make_table_lattice_lookup(
const std::unordered_map<std::string, std::vector<quanpin::LatticeLexeme>> &table)
{
return [&table](const quanpin::Segments &span) {
const auto it = table.find(quanpin::join_segments(span));
return it == table.end() ? std::vector<quanpin::LatticeLexeme>{} : it->second;
};
}
void test_word_lattice()
{
using quanpin::LatticeLexeme;
fmt::println("==== Word Lattice ====");
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["nie"] = {{"nie", "捏", 8000}, {"nie", "聂", 4000}, {"nie", "镊", 3000}};
table["zi"] = {{"zi", "子", 9000}};
table["nie'zi"] = {{"nie'zi", "镊子", 18000}, {"nie'zi", "孽子", 2000}};
const auto paths = quanpin::decode_word_lattice({"nie", "zi"}, make_table_lattice_lookup(table));
expect(!paths.empty() && paths.front().sentence == "镊子",
fmt::format("Expected 镊子 for nie zi, got '{}'", paths.empty() ? "" : paths.front().sentence));
}
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["gao"] = {{"gao", "高", 12000}, {"gao", "搞", 11000}};
table["tan"] = {{"tan", "谈", 9000}, {"tan", "碳", 4000}, {"tan", "摊", 3500}};
table["gang"] = {{"gang", "刚", 9000}, {"gang", "钢", 5000}, {"gang", "岗", 4000}};
table["nie"] = {{"nie", "捏", 8000}, {"nie", "镊", 3000}};
table["zi"] = {{"zi", "子", 9000}};
table["gao'tan"] = {{"gao'tan", "高谈", 20000}};
table["tan'gang"] = {{"tan'gang", "碳钢", 16000}};
table["nie'zi"] = {{"nie'zi", "镊子", 18000}};
const auto paths =
quanpin::decode_word_lattice({"gao", "tan", "gang", "nie", "zi"}, make_table_lattice_lookup(table));
expect(!paths.empty() && paths.front().sentence == "高碳钢镊子",
fmt::format("Expected 高碳钢镊子, got '{}'", paths.empty() ? "" : paths.front().sentence));
}
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["gao"] = {{"gao", "高", 12000}};
table["tan"] = {{"tan", "碳", 4000}};
table["gang"] = {{"gang", "钢", 5000}};
table["tan'gang"] = {{"tan'gang", "碳钢", 16000}};
std::vector<WordItem> candidates;
candidates.emplace_back("gktjgh", "高碳钢", 50000, CandidateSource::Database, "gao'tan'gang");
quanpin::merge_lattice_candidates(candidates, {"gao", "tan", "gang"}, make_table_lattice_lookup(table),
"gktjgh");
expect(candidates.front().word == "高碳钢" && candidates.front().source == CandidateSource::Database,
fmt::format("Expected Database 高碳钢 first, got '{}'", candidates.front().word));
}
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["gao"] = {{"gao", "高", 12000}};
table["tan"] = {{"tan", "碳", 4000}, {"tan", "谈", 9000}};
table["gang"] = {{"gang", "钢", 5000}, {"gang", "刚", 9000}};
table["nie"] = {{"nie", "镊", 3000}};
table["zi"] = {{"zi", "子", 9000}};
table["tan'gang"] = {{"tan'gang", "碳钢", 16000}};
table["nie'zi"] = {{"nie'zi", "镊子", 18000}};
std::vector<WordItem> candidates;
candidates.emplace_back("gktjghnxzi", "高谈刚捏子", 1, CandidateSource::Fallback);
quanpin::merge_lattice_candidates(candidates, {"gao", "tan", "gang", "nie", "zi"},
make_table_lattice_lookup(table), "gktjghnxzi");
expect(candidates.front().word == "高碳钢镊子",
fmt::format("Expected lattice 高碳钢镊子 ahead of Fallback, got '{}'", candidates.front().word));
expect(find_candidate(candidates, "高谈刚捏子") != nullptr, "Expected Fallback 高谈刚捏子 to remain.");
}
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["xing"] = {{"xing", "性", 8000}};
table["neng"] = {{"neng", "能", 8000}};
table["hen"] = {{"hen", "很", 20000}, {"hen", "狠", 3000}};
table["la"] = {{"la", "拉", 5000}};
table["ji"] = {{"ji", "圾", 4000}};
table["xing'neng"] = {{"xing'neng", "性能", 15000}};
table["la'ji"] = {{"la'ji", "垃圾", 14000}};
const auto paths =
quanpin::decode_word_lattice({"xing", "neng", "hen", "la", "ji"}, make_table_lattice_lookup(table));
expect(!paths.empty() && paths.front().sentence.find("很垃圾") != std::string::npos &&
paths.front().sentence.find("狠垃圾") == std::string::npos,
fmt::format("Expected 很垃圾 over 狠垃圾, got '{}'", paths.empty() ? "" : paths.front().sentence));
}
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["nie"] = {{"nie", "捏", 8000}};
table["zi"] = {{"zi", "子", 9000}};
table["nie'zi"] = {{"nie'zi", "镊子", 18000}};
std::vector<WordItem> candidates;
candidates.emplace_back("nxzi", "镊子", 18000, CandidateSource::Database, "nie'zi");
quanpin::merge_lattice_candidates(candidates, {"nie", "zi"}, make_table_lattice_lookup(table), "nxzi");
expect(candidates.size() == 1 && candidates.front().source == CandidateSource::Database,
"Two-syllable merge is a no-op; exact SQLite already covers the key.");
}
{
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["g"] = {{"g", "个", 100}};
table["k"] = {{"k", "可", 100}};
table["t"] = {{"t", "他", 100}};
table["g'k't"] = {{"g'k't", "个可他", 50}};
std::vector<WordItem> candidates;
quanpin::merge_lattice_candidates(candidates, {"g", "k", "t"}, make_table_lattice_lookup(table), "gkt");
expect(candidates.empty(), "Abbreviated quanpin segments must not produce lattice candidates.");
}
}
void test_quanpin_order_corrections()
{
const std::vector<std::pair<std::string, std::string>> cases = {
{"laing", "liang"}, {"haung", "huang"}, {"bain", "bian"}, {"daun", "duan"},
{"laio", "liao"}, {"mihng", "ming"}, {"ahng", "hang"}, {"behng", "beng"},
{"agn", "ang"}, {"zagn", "zang"}, {"egn", "eng"}, {"zhegn", "zheng"},
{"jv", "ju"}, {"wojv", "wo'ju"}, {"wo'jv", "wo'ju"}, {"woxainxin", "wo'xian'xin"},
};
for (const auto &[typed, expected] : cases)
{
const auto cuts = quanpin::cut_pinyin_by_mode(typed, "correction");
expect(!cuts.empty(), fmt::format("Expected '{}' to produce a corrected path.", typed));
expect(quanpin::join_segments(cuts.front()) == expected,
fmt::format("Expected '{}' to normalize to '{}', got '{}'.", typed, expected,
quanpin::join_segments(cuts.front())));
}
const auto ambiguous = quanpin::cut_pinyin_by_mode("cehng", "correction");
expect(ambiguous.size() >= 2, "Expected cehng to retain both valid interpretations.");
expect(quanpin::join_segments(ambiguous.front()) == "cheng", "Expected cheng to be the primary interpretation.");
expect(std::any_of(ambiguous.begin(), ambiguous.end(),
[](const quanpin::Segments &segments) { return quanpin::join_segments(segments) == "ceng"; }),
"Expected ceng to remain available as an alternative interpretation.");
const auto ambiguous_ahng = quanpin::cut_pinyin_by_mode("ahng", "correction");
expect(ambiguous_ahng.size() >= 2, "Expected ahng to retain both valid interpretations.");
expect(quanpin::join_segments(ambiguous_ahng.front()) == "hang", "Expected hang to be the primary interpretation.");
expect(std::any_of(ambiguous_ahng.begin(), ambiguous_ahng.end(),
[](const quanpin::Segments &segments) { return quanpin::join_segments(segments) == "ang"; }),
"Expected ang to remain available as an alternative interpretation.");
}
namespace
{ // Minimal deterministic dictionary for the mask matrix: '上' exists only under the
// corrected key 'shang', so a leading 上 proves the corrected path actually ran.
std::filesystem::path create_autocorrect_probe_database()
{
const fs::path path = fs::temp_directory_path() / "msime-quanpin-autocorrect-mask-test.db";
std::error_code ec;
fs::remove(path, ec);
sqlite3 *db = nullptr;
if (sqlite3_open(path.string().c_str(), &db) != SQLITE_OK)
{
throw std::runtime_error("Failed to create the autocorrect probe database.");
}
const char *sql = "CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_4_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shang','s','上',100);"
"INSERT INTO tbl_2_s VALUES('shang''zhi','sz','上至',100);"
"INSERT INTO tbl_2_j VALUES('jian''du','jd','监督',100);"
"INSERT INTO tbl_4_s VALUES('sa''huang''na''ge','shng','撒谎那个',1000);";
const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr);
sqlite3_close(db);
if (result != SQLITE_OK)
{
fs::remove(path, ec);
throw std::runtime_error("Failed to initialize the autocorrect probe database.");
}
return path;
}
} // namespace
void test_quanpin_autocorrect_switches_and_guard()
{
fmt::println("==== Quanpin Autocorrect Switch Matrix And Jianpin Guard ====");
const unsigned none = 0;
const unsigned transposition_only = quanpin::kAutocorrectTransposition;
const unsigned neighbor_only = quanpin::kAutocorrectNeighbor;
const unsigned both = transposition_only | neighbor_only;
// Cut-level switch matrix: 'sahng' is a transposition fix, 'shabg' a neighbor
// fix, and each type bit must enable exactly its own family (AC1-AC4).
expect(quanpin::autocorrect_cut("sahng", none).empty(), "Both switches off must disable the correction cut.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("sahng", transposition_only)) == "shang",
"Transposition-only must correct 'sahng'.");
expect(quanpin::autocorrect_cut("sahng", neighbor_only).empty(),
"Neighbor-only must not correct the transposition case 'sahng'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("sahng", both)) == "shang",
"Both switches on must correct 'sahng'.");
expect(quanpin::autocorrect_cut("shabg", none).empty(), "Both switches off must disable the correction cut.");
expect(quanpin::autocorrect_cut("shabg", transposition_only).empty(),
"Transposition-only must not correct the neighbor case 'shabg'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("shabg", neighbor_only)) == "shang",
"Neighbor-only must correct 'shabg'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("shabg", both)) == "shang",
"Both switches on must correct 'shabg'.");
// Jianpin-shape guard: one or more legal syllables plus at most one trailing
// letter is user intent, never a typo (AC5).
expect(quanpin::looks_like_syllable_with_jianpin_tail("zheg"),
"'zheg' (zhe + g) must be detected as jianpin intent.");
expect(quanpin::looks_like_syllable_with_jianpin_tail("keneng"),
"A fully legal spelling also satisfies the shape predicate.");
expect(!quanpin::looks_like_syllable_with_jianpin_tail("sahng"),
"'sahng' leaves a 3-letter tail and must stay correctable.");
expect(!quanpin::looks_like_syllable_with_jianpin_tail("shabg"),
"'shabg' leaves a 2-letter tail and must stay correctable.");
expect(!quanpin::looks_like_syllable_with_jianpin_tail("xi'an"), "Manual delimiters never take part in the guard.");
expect(!quanpin::looks_like_syllable_with_jianpin_tail("wj"),
"Pure-consonant jianpin must stay correctable at the predicate level.");
expect(!quanpin::looks_like_syllable_with_jianpin_tail("bqng"),
"3+ letter all-consonant strings stay correctable by design (no multi-letter jianpin).");
// A complete syllable plus a lone trailing VOWEL is not jianpin (no vowel is
// a jianpin initial); it reads as a transposition typo, so the guard must
// let it through to correction ("gau" = ga + u -> gua).
expect(!quanpin::looks_like_syllable_with_jianpin_tail("gau"),
"'gau' (ga + trailing vowel u) must stay correctable, not be read as jianpin.");
expect(!quanpin::looks_like_syllable_with_jianpin_tail("hau"),
"'hau' (ha + trailing vowel u) must stay correctable, not be read as jianpin.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("gau", transposition_only)) == "gua",
"'gau' must correct to 'gua' via transposition once the guard lets it through.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("bqng", neighbor_only)) == "bang",
"'bqng' -> bang must remain a valid neighbor correction.");
expect(quanpin::autocorrect_cut("zheg", both).empty(),
"The cleaned table must offer no correction path for 'zheg' (2-letter keys are gone).");
// Deletion bit (phase 2): "shng" (dropped "a") is a deletion fix; only
// that bit may correct it, and the legacy switches keep their own families.
const unsigned deletion_only = quanpin::kAutocorrectDeletion;
const unsigned all = transposition_only | neighbor_only | deletion_only;
expect(quanpin::join_segments(quanpin::autocorrect_cut("shng", deletion_only)) == "shang",
"Deletion-only must correct 'shng'.");
expect(quanpin::autocorrect_cut("shng", transposition_only).empty(),
"Transposition-only must not correct the deletion case 'shng'.");
expect(quanpin::autocorrect_cut("shng", neighbor_only).empty(),
"Neighbor-only must not correct the deletion case 'shng'.");
expect(quanpin::autocorrect_cut("sahng", deletion_only).empty() == false &&
quanpin::join_segments(quanpin::autocorrect_cut("sahng", deletion_only)) == "sa'hang",
"Bits gate tables, not intents: deletion-only still explains 'sahng' via sa + hng -> hang.");
expect(quanpin::autocorrect_cut("shabg", deletion_only).empty(),
"Deletion-only must not correct the neighbor case 'shabg'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("shngzhk", all)) == "shang'zhi",
"A deletion edge followed by a neighbor edge must cut 'shngzhk'.");
expect(quanpin::autocorrect_cut("shngzhk", both).empty(),
"Without the deletion bit 'shngzhk' must stay unexplained.");
// Insertion bit (fourth type): "shangg" (doubled g) and "sjhang" (j is a
// QWERTY neighbor of h) are insertion fixes; only that bit may correct them.
const unsigned insertion_only = quanpin::kAutocorrectInsertion;
const unsigned all_four = all | insertion_only;
expect(quanpin::join_segments(quanpin::autocorrect_cut("shangg", insertion_only)) == "shang",
"Insertion-only must correct 'shangg'.");
expect(quanpin::autocorrect_cut("shangg", transposition_only).empty(),
"Transposition-only must not correct the insertion case 'shangg'.");
expect(quanpin::autocorrect_cut("shangg", deletion_only).empty(),
"Deletion-only must not correct the insertion case 'shangg'.");
expect(quanpin::autocorrect_cut("shangg", neighbor_only).empty(),
"Neighbor-only must not correct the insertion case 'shangg'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("sjhang", insertion_only)) == "shang",
"Insertion-only must correct the neighbor-key insertion 'sjhang'.");
expect(quanpin::autocorrect_cut("sjhang", neighbor_only).empty(),
"Neighbor-only must not correct the insertion case 'sjhang'.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("shanggzhi", all_four)) == "shang'zhi",
"An insertion edge followed by a legal tail must cut 'shanggzhi'.");
// Jianpin guard shapes sit INSIDE the insertion table (zher = zhe + r, and
// r is a QWERTY neighbor of e): the BFS cut exists by design, the shape
// guard must fire before it at the dictionary layer (end-to-end in the
// display test below).
expect(quanpin::looks_like_syllable_with_jianpin_tail("zher"),
"'zher' (zhe + r) must be detected as jianpin intent.");
expect(!quanpin::autocorrect_cut_detail("zher", insertion_only).empty(),
"The insertion key 'zher' is in the BFS search space by design; the guard is the dictionary layer's job.");
// k-best (phase 2): ambiguous keys keep parallel readings for query-time
// disambiguation; k = 1 stays consistent with the single-cut projection.
const auto cut_reading = [](const quanpin::AutocorrectCut &cut) {
quanpin::Segments syllables;
for (const auto &segment : cut.segments)
{
syllables.push_back(segment.syllable);
}
return quanpin::join_segments(syllables);
};
const auto ahan_cuts = quanpin::autocorrect_cut_kbest("ahan", both);
expect(ahan_cuts.size() >= 2, "'ahan' must keep both the shan and zhan readings.");
expect(cut_reading(ahan_cuts[0]) == "shan" && cut_reading(ahan_cuts[1]) == "zhan",
"The first two 'ahan' cuts must be shan then zhan (edge count, weight, table order).");
const auto shng_cuts = quanpin::autocorrect_cut_kbest("shng", all);
expect(shng_cuts.size() >= 2 && cut_reading(shng_cuts[0]) == "shang",
"'shng' must cut to shang first with sheng as a kept alternative.");
bool has_sheng = false;
for (const auto &cut : shng_cuts)
{
has_sheng = has_sheng || cut_reading(cut) == "sheng";
}
expect(has_sheng, "'shng' must keep the sheng reading alongside shang.");
const auto sahng_cuts = quanpin::autocorrect_cut_kbest("sahng", all);
expect(sahng_cuts.size() >= 2 && cut_reading(sahng_cuts[0]) == "shang" && cut_reading(sahng_cuts[1]) == "sa'hang",
"Within one edge the transposition weight (10) must outrank the split deletion (11).");
// Cost tiers must be reported on the cut so the query layer keeps frequency
// disambiguation inside one tier: gau -> gua (transposition, weight 10) is
// strictly cheaper than gau -> gai (neighbor, weight 13), so gua leads and
// the two land in different tiers regardless of dictionary frequency.
const auto gau_cuts = quanpin::autocorrect_cut_kbest("gau", both);
expect(gau_cuts.size() >= 2 && cut_reading(gau_cuts[0]) == "gua",
"'gau' must rank the transposition reading gua first.");
const auto gau_gai = std::find_if(gau_cuts.begin(), gau_cuts.end(),
[&](const quanpin::AutocorrectCut &cut) { return cut_reading(cut) == "gai"; });
expect(gau_gai != gau_cuts.end(), "'gau' must keep the neighbor reading gai as an alternative.");
expect(gau_gai != gau_cuts.end() && !gau_cuts.front().same_cost_as(*gau_gai) &&
gau_cuts.front().weight < gau_gai->weight,
"gua (weight 10) and gai (weight 13) must be reported as different cost tiers.");
// Ambiguous insertion keys keep parallel readings (query-time
// disambiguation): baio -> {biao via transposition, bai, bao via
// insertion}. Same edge count, so weight orders them: 10 < 12.
const auto baio_cuts = quanpin::autocorrect_cut_kbest("baio", all_four);
expect(baio_cuts.size() >= 3 && cut_reading(baio_cuts[0]) == "biao" && cut_reading(baio_cuts[1]) == "bai" &&
cut_reading(baio_cuts[2]) == "bao",
"'baio' must rank the transposition reading (10) before the insertion readings (12) within one edge.");
const std::pair<const char *, unsigned> projection_inputs[] = {
{"sahng", both}, {"shabg", both}, {"sahnguai", both},
{"ahan", both}, {"zheg", both}, {"keneng", both},
{"shng", all}, {"zhngu", all}, {"shangg", insertion_only},
{"sjhang", all_four},
};
for (const auto &[input, types] : projection_inputs)
{
const auto single = quanpin::autocorrect_cut(input, types);
const auto top1 = quanpin::autocorrect_cut_kbest(input, types, 1);
expect(top1.size() == (single.empty() ? 0u : 1u) &&
(single.empty() || cut_reading(top1.front()) == quanpin::join_segments(single)),
std::string("k=1 must agree with autocorrect_cut for '") + input + "'.");
}
// Dictionary-level matrix against a deterministic probe database.
const auto db_path = create_autocorrect_probe_database();
const auto is_shang = [](const WordItem &item) { return item.word == "上"; };
{
QuanpinDictionary dictionary(db_path.string());
const auto corrected = dictionary.query("sahng", "sa'h'n'g", both);
expect(!corrected.empty() && corrected.front().word == "上",
"Both switches on must put the corrected candidate first.");
expect(corrected.front().canonical_pinyin == "shang",
"The corrected candidate must keep the corrected key as canonical pinyin.");
const auto off = dictionary.query("sahng", "sa'h'n'g", none);
expect(std::none_of(off.begin(), off.end(), is_shang),
"Both switches off must keep the corrected candidate out (AC1).");
expect(std::any_of(off.begin(), off.end(), [](const WordItem &item) { return item.word == "撒谎那个"; }),
"The legacy fallback candidates must survive with both switches off.");
const auto transposed = dictionary.query("sahng", "sa'h'n'g", transposition_only);
expect(!transposed.empty() && transposed.front().word == "上",
"Transposition-only must correct 'sahng' at the dictionary layer (AC2).");
const auto neighbor_denied = dictionary.query("sahng", "sa'h'n'g", neighbor_only);
expect(std::none_of(neighbor_denied.begin(), neighbor_denied.end(), is_shang),
"Neighbor-only must not correct the transposition case 'sahng' (AC2).");
const auto neighbor_corrected = dictionary.query("shabg", "sha'b'g", neighbor_only);
expect(!neighbor_corrected.empty() && neighbor_corrected.front().word == "上",
"Neighbor-only must correct 'shabg' at the dictionary layer (AC3).");
// 门控语义按纠错标记验证:transposition-only 不产生针对 'shabg' 的纠错
// 候选。不能断言"上"完全缺席——序列前缀查询会以 sha 前缀命中 'shang'
// 键,那是与纠错无关的既有行为。
const auto transposition_denied = dictionary.query("shabg", "sha'b'g", transposition_only);
expect(std::none_of(transposition_denied.begin(), transposition_denied.end(),
[](const WordItem &item) { return item.corrected_from == "shabg"; }),
"Transposition-only must not correct the neighbor case 'shabg' (AC3).");
const auto multi = dictionary.query("sahngzhi", "sa'h'n'g'zhi", both);
expect(!multi.empty() && multi.front().word == "上至",
"Cross-syllable correction must survive the mask wiring.");
// 空基础切分(丢声母:jian -> ian)也要进纠错:correction 模式对该类错拼
// 切不出任何段,旧门 !segments.empty() 会整体跳过纠错。BFS 从原始字母
// 纠正,备选切分 jian'du 经查询期词频合并成为首位。
const auto dropped_initial = dictionary.query("iandu", "", all);
expect(!dropped_initial.empty() && dropped_initial.front().word == "监督" &&
dropped_initial.front().corrected_from == "iandu",
"An empty base segmentation (dropped initial) must still reach the correction path.");
const auto dropped_initial_off = dictionary.query("iandu", "", none);
expect(std::none_of(dropped_initial_off.begin(), dropped_initial_off.end(),
[](const WordItem &item) { return item.word == "监督"; }),
"With autocorrection off the dropped-initial typo must not resolve through a corrected key.");
// The guard fires before the BFS, so a jianpin-shaped input must resolve
// through its raw segmentation instead of a corrected key such as 'zu'ge'.
(void)dictionary.query("zheg", "", both);
expect(dictionary.get_pinyin_segmentation() != "zu'ge",
"A jianpin-shaped input must not resolve through a corrected key.");
// Correction composes with a trailing jianpin tail: the k-best search
// fails on the whole input (the tail is an incomplete syllable), so the
// head is corrected and the tail carried through to the jianpin query.
(void)dictionary.query("hauzh", "", both);
expect(dictionary.get_pinyin_segmentation() == "hua'zh",
"A correctable head plus a jianpin tail must compose: hauzh -> hua'zh.");
(void)dictionary.query("hauz", "", both);
expect(dictionary.get_pinyin_segmentation() == "hua'z",
"The jianpin tail may be a single initial: hauz -> hua'z.");
// A correctly spelled head with the same jianpin tail is untouched.
(void)dictionary.query("huazh", "", both);
expect(dictionary.get_pinyin_segmentation() == "hua'zh",
"A correctly spelled jianpin input keeps its plain segmentation.");
// With autocorrection off, the mistyped head must not compose either.
(void)dictionary.query("hauzh", "", none);
expect(dictionary.get_pinyin_segmentation() != "hua'zh",
"With autocorrection off, hauzh must not resolve through a corrected key.");
// Neighbor corrections are excluded from the composition head: without
// that guard a deletion-shaped input becomes noise via a low-confidence
// neighbor head plus a speculative jianpin tail (shng -> sun'g).
(void)dictionary.query("shng", "sh'n'g", both);
expect(dictionary.get_pinyin_segmentation() != "sun'g",
"A neighbor head must not compose with a jianpin tail (shng must not become sun'g).");
}
std::error_code cleanup_ec;
fs::remove(db_path, cleanup_ec);
// Generated-table invariants shared by all three type tables (AC6).
// Ambiguity is kept: one wrong key may map to several syllables, so
// uniqueness is asserted per (wrong, correct) pair instead of per key.
const auto &legal = quanpin::intact_pinyin_set();
std::unordered_set<std::string> seen_pairs;
size_t total_entries = 0;
const std::pair<const quanpin::autocorrect::Entry *, std::size_t> tables[] = {
{quanpin::autocorrect::kTranspositionEntries, quanpin::autocorrect::kTranspositionCount},
{quanpin::autocorrect::kNeighborEntries, quanpin::autocorrect::kNeighborCount},
{quanpin::autocorrect::kDeletionEntries, quanpin::autocorrect::kDeletionCount},
{quanpin::autocorrect::kInsertionEntries, quanpin::autocorrect::kInsertionCount},
};
for (const auto &[entries, count] : tables)
{
for (std::size_t i = 0; i < count; ++i)
{
const std::string wrong(entries[i].wrong);
// correct 是生成音节表的 16-bit 下标,断言前先解引用。
const std::string correct(quanpin::autocorrect::kCorrectSyllables[entries[i].correct]);
expect(wrong.size() >= 3, "2-letter keys belong to the jianpin space and must not be generated.");
expect(legal.find(wrong) == legal.end(), "A correction key must never shadow a legal syllable.");
expect(legal.find(correct) != legal.end(), "A correction target must be a legal syllable.");
expect(seen_pairs.insert(wrong + '>' + correct).second,
"(wrong, correct) pairs must be unique across all tables.");
++total_entries;
}
}
expect(total_entries >= 4700, "The generated tables unexpectedly shrank.");
}
namespace
{ // 显示/标记用例的独立探针库:shang、shang'hao、ke'neng、nv、sa'huang'na'ge 最小键集。
std::filesystem::path create_autocorrect_display_probe_database()
{
const fs::path path = fs::temp_directory_path() / "msime-quanpin-autocorrect-display-test.db";
std::error_code ec;
fs::remove(path, ec);
sqlite3 *db = nullptr;
if (sqlite3_open(path.string().c_str(), &db) != SQLITE_OK)
{
throw std::runtime_error("Failed to create the autocorrect display probe database.");
}
const char *sql = "CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_k(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_4_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shang','s','上',100);"
"INSERT INTO tbl_1_n VALUES('nv','n','女',100);"
"INSERT INTO tbl_2_s VALUES('shang''hao','sh','上好',100);"
"INSERT INTO tbl_2_j VALUES('jian''du','jd','监督',100);"
"INSERT INTO tbl_2_k VALUES('ke''neng','kn','可能',100);"
"INSERT INTO tbl_4_s VALUES('sa''huang''na''ge','shng','撒谎那个',1000);";
const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr);
sqlite3_close(db);
if (result != SQLITE_OK)
{
fs::remove(path, ec);
throw std::runtime_error("Failed to initialize the autocorrect display probe database.");
}
return path;
}
std::size_t count_marked(const std::vector<WordItem> &items)
{
return static_cast<std::size_t>(
std::count_if(items.begin(), items.end(), [](const WordItem &item) { return !item.corrected_from.empty(); }));
}
void type_display_session(metasequoia::InputSession &session, const std::string &text)
{
for (const char character : text)
{
if (!session.handle_character(character).handled)
{
throw std::runtime_error("A display-test pinyin character was not handled.");
}
}
}
} // namespace
void test_quanpin_autocorrect_display()
{
fmt::println("==== Quanpin Autocorrect Display (raw-letter preedit + candidate marking) ====");
const unsigned none = 0;
const unsigned transposition_only = quanpin::kAutocorrectTransposition;
const unsigned neighbor_only = quanpin::kAutocorrectNeighbor;
const unsigned both = transposition_only | neighbor_only;
// utils 级:纠错切分携带原始区间,raw span 从回溯位置直接推出。
const auto sahng_cut = quanpin::autocorrect_cut_detail("sahng", both);
expect(sahng_cut.segments.size() == 1, "'sahng' must cut into a single corrected segment.");
expect(sahng_cut.segments[0].syllable == "shang" && sahng_cut.segments[0].raw_text == "sahng" &&
sahng_cut.segments[0].start == 0 && sahng_cut.segments[0].corrected,
"The 'sahng' segment must keep the raw letters alongside the corrected syllable.");
const auto shabg_cut = quanpin::autocorrect_cut_detail("shabg", both);
expect(shabg_cut.segments.size() == 1 && shabg_cut.segments[0].syllable == "shang" &&
shabg_cut.segments[0].raw_text == "shabg" && shabg_cut.segments[0].start == 0 &&
shabg_cut.segments[0].corrected,
"The 'shabg' segment must keep the raw letters alongside the corrected syllable.");
const auto sahnghao_cut = quanpin::autocorrect_cut_detail("sahnghao", both);
expect(sahnghao_cut.segments.size() == 2, "'sahnghao' must cut into two segments.");
expect(sahnghao_cut.segments[0].syllable == "shang" && sahng_cut.segments[0].raw_text == "sahng" &&
sahnghao_cut.segments[0].start == 0 && sahnghao_cut.segments[0].corrected,
"The first 'sahnghao' segment must keep the raw letters of the corrected part.");
expect(sahnghao_cut.segments[1].syllable == "hao" && sahnghao_cut.segments[1].raw_text == "hao" &&
sahnghao_cut.segments[1].start == 5 && !sahnghao_cut.segments[1].corrected,
"The untouched tail of 'sahnghao' must keep its raw span.");
expect(quanpin::autocorrect_cut_detail("zheg", both).empty(),
"The cleaned table must leave the jianpin shape 'zheg' unexplained by the BFS.");
expect(quanpin::autocorrect_cut_detail("keneng", both).empty(),
"A fully legal spelling must produce no correction cut.");
expect(quanpin::autocorrect_cut_detail("sahng", none).empty(),
"Both switches off must disable the range-carrying cut too.");
expect(quanpin::autocorrect_cut_detail("xi'an", both).empty(),
"Manual delimiters must disable the range-carrying cut.");
expect(quanpin::join_segments(quanpin::autocorrect_cut("sahng", both)) == "shang",
"The Segments wrapper must stay a projection of the detail cut.");
// 不等长边(漏字):4 字母 raw "zhng" 映到 5 字母音节 zhang,第二段从 raw
// 偏移 4 接 "gu";位置推进跟随 raw_length 而非音节长度。
const unsigned all = transposition_only | neighbor_only | quanpin::kAutocorrectDeletion;
const auto zhnggu_cut = quanpin::autocorrect_cut_detail("zhnggu", all);
expect(zhnggu_cut.segments.size() == 2, "'zhnggu' must cut into two segments via a deletion edge.");
expect(zhnggu_cut.segments[0].syllable == "zhang" && zhnggu_cut.segments[0].raw_text == "zhng" &&
zhnggu_cut.segments[0].start == 0 && zhnggu_cut.segments[0].corrected,
"The deletion segment must map 4 raw letters onto the 5-letter syllable.");
expect(zhnggu_cut.segments[1].syllable == "gu" && zhnggu_cut.segments[1].raw_text == "gu" &&
zhnggu_cut.segments[1].start == 4 && !zhnggu_cut.segments[1].corrected,
"The legal tail of 'zhnggu' must start at the raw offset the deletion edge left.");
// "zhngu"(3+2):更短的不等长拆分,第二段起点在 3。
const auto zhngu_cut = quanpin::autocorrect_cut_detail("zhngu", all);
expect(zhngu_cut.segments.size() == 2 && zhngu_cut.segments[0].raw_text == "zhn" &&
zhngu_cut.segments[0].corrected && zhngu_cut.segments[1].raw_text == "gu" &&
zhngu_cut.segments[1].start == 3 && !zhngu_cut.segments[1].corrected,
"'zhngu' must split 3+2 with the legal tail starting at raw offset 3.");
// 插入(第四类):raw 比音节长 1("shangg" -> shang);位置推进同样跟随
// raw_length 而非音节长度。
const unsigned all_four = all | quanpin::kAutocorrectInsertion;
const auto shangg_cut = quanpin::autocorrect_cut_detail("shangg", all_four);
expect(shangg_cut.segments.size() == 1, "'shangg' must cut into a single corrected segment.");
expect(shangg_cut.segments[0].syllable == "shang" && shangg_cut.segments[0].raw_text == "shangg" &&
shangg_cut.segments[0].start == 0 && shangg_cut.segments[0].corrected,
"The insertion segment must map 6 raw letters onto the 5-letter syllable.");
const auto shangghao_cut = quanpin::autocorrect_cut_detail("shangghao", all_four);
expect(shangghao_cut.segments.size() == 2, "'shangghao' must cut into two segments.");
// 同为 1 条纠错边时权重定序:漏字解释(shang + ghao->gao,11)胜过插入
// 解释(shangg->shang + hao,12),插入不劫持更便宜的解释。
expect(shangghao_cut.segments[0].syllable == "shang" && shangghao_cut.segments[0].raw_text == "shang" &&
shangghao_cut.segments[0].start == 0 && !shangghao_cut.segments[0].corrected,
"The cheaper deletion reading (weight 11) must win over the insertion reading (12).");
expect(shangghao_cut.segments[1].syllable == "gao" && shangghao_cut.segments[1].raw_text == "ghao" &&
shangghao_cut.segments[1].start == 5 && shangghao_cut.segments[1].corrected,
"The 'ghao' tail must be corrected through the deletion key.");
// 纯插入解释的 raw 区间:无更便宜的捷径时插入边胜出,第二段起点跟随
// raw_length(6 而非 5)。
const auto shanggni_cut = quanpin::autocorrect_cut_detail("shanggni", all_four);
expect(shanggni_cut.segments.size() == 2, "'shanggni' must cut into two segments via an insertion edge.");
expect(shanggni_cut.segments[0].syllable == "shang" && shanggni_cut.segments[0].raw_text == "shangg" &&
shanggni_cut.segments[0].start == 0 && shanggni_cut.segments[0].corrected,
"The insertion segment must keep the raw letters of the corrected part.");
expect(shanggni_cut.segments[1].syllable == "ni" && shanggni_cut.segments[1].raw_text == "ni" &&
shanggni_cut.segments[1].start == 6 && !shanggni_cut.segments[1].corrected,
"The legal tail of 'shanggni' must start at the raw offset the insertion edge left.");
// 字典级标记:候选字母 == 主切分字母 且 主切分字母 != 原始字母 才标记。
const auto db_path = create_autocorrect_display_probe_database();
{
QuanpinDictionary dictionary(db_path.string());
const auto corrected = dictionary.query("sahng", "sa'h'n'g", both);
expect(!corrected.empty() && corrected.front().word == "上" && corrected.front().corrected_from == "sahng",
"The corrected first candidate must carry the typed input as corrected_from (AC7).");
const auto legacy = std::find_if(corrected.begin(), corrected.end(),
[](const WordItem &item) { return item.word == "撒谎那个"; });
expect(legacy != corrected.end() && legacy->corrected_from.empty(),
"The legacy fallback tail must stay unmarked.");
const auto off = dictionary.query("sahng", "sa'h'n'g", none);
expect(count_marked(off) == 0,
"No candidate may be marked while the primary segmentation keeps the typed letters.");
// 别名层改写(真实方案层会把校正后的 segmentation 传进来)与开关无关,照样标记。
const auto alias_like = dictionary.query("sahng", "shang", none);
expect(!alias_like.empty() && alias_like.front().word == "上" && alias_like.front().corrected_from == "sahng",
"Alias-layer corrected candidates must be labelled regardless of the switches.");
// 前缀候选不标记:只有字母等于主切分的整词候选才带 corrected_from。
const auto full = dictionary.query("sahnghao", "sa'h'n'g'hao", both);
expect(!full.empty() && full.front().word == "上好" && full.front().corrected_from == "sahnghao",
"The full-length corrected candidate must be labelled with the typed input.");
const auto prefix =
std::find_if(full.begin(), full.end(), [](const WordItem &item) { return item.word == "上"; });
expect(prefix != full.end() && prefix->corrected_from.empty(), "Partial prefix candidates must stay unmarked.");
expect(count_marked(full) == 1, "Exactly the full-length corrected candidate may be marked for 'sahnghao'.");
const auto keneng = dictionary.query("keneng", "ke'neng", both);
expect(count_marked(keneng) == 0, "A legal spelling must produce no marks.");
expect(count_marked(dictionary.query("wj", "w'j", both)) == 0, "Jianpin input must produce no marks (AC5).");
const auto nv = dictionary.query("nv", "nv", both);
expect(!nv.empty() && nv.front().word == "女" && nv.front().corrected_from.empty(),
"The u-umlaut 'v' spelling must stay unmarked.");
// 漏字(阶段 2):deletion 位开时 "shng" 经纠错键命中目标词并标记;
// 关掉则回到现状(无标记,乱码后备尾)。打标按字母比较,天然兼容不等长边。
const auto deletion = dictionary.query("shng", "sh'n'g", all);
expect(!deletion.empty() && deletion.front().word == "上" && deletion.front().corrected_from == "shng",
"A deletion typo must resolve through the corrected key and carry corrected_from.");
const auto deletion_off = dictionary.query("shng", "sh'n'g", both);
expect(count_marked(deletion_off) == 0, "Without the deletion bit 'shng' must stay uncorrected and unmarked.");
// 插入(阶段 4):insertion 位开时 "sshang"(双打 s)经纠错键命中目标词
// 并带 corrected_from(AC5);关闭 insertion 位则无纠错标记。注:结尾单
// 插入(如 shangg = shang + g)属简拼尾形状,被词典门按设计拦截,
// 与 zher 同理 —— insertion 的可达面在中间/开头插入与多音节输入。
const auto insertion = dictionary.query("sshang", "", all_four);
expect(!insertion.empty() && insertion.front().word == "上" && insertion.front().corrected_from == "sshang",
"An insertion typo must resolve through the corrected key and carry corrected_from.");
const auto insertion_off = dictionary.query("sshang", "", all);
expect(count_marked(insertion_off) == 0,
"Without the insertion bit 'sshang' must stay uncorrected and unmarked.");
}
// 会话级 preedit:get_pinyin_segmentation_with_cases 必须画原始字母。
const fs::path session_dir = fs::temp_directory_path() / "msime-quanpin-autocorrect-display-session";
std::error_code cleanup_ec;
fs::remove_all(session_dir, cleanup_ec);
fs::create_directories(session_dir / "helpcodes");
fs::copy_file(db_path, session_dir / "msime.db", fs::copy_options::overwrite_existing);
{
std::ofstream helpcodes(session_dir / "helpcodes" / "helpcode.txt");
helpcodes << "你=ab\n";
}
metasequoia::RuntimePaths paths;
paths.resources = session_dir;
paths.user_data = session_dir;
paths.cache = session_dir;
paths.dictionaries = session_dir;
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "sahng");
expect(session.get_pinyin_segmentation_with_cases() == "sahng",
"The preedit must show the typed letters, not the alias rewrite (AC7).");
expect(!session.candidates().empty() && session.candidates().front().word == "上" &&
session.candidates().front().corrected_from == "sahng",
"The first candidate for 'sahng' must be the corrected 上 with corrected_from.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "sahnghao");
expect(session.get_pinyin_segmentation_with_cases() == "sahng'hao",
"The preedit must keep the typed letters and re-separate at the cut positions.");
expect(!session.candidates().empty() && session.candidates().front().word == "上好" &&
session.candidates().front().corrected_from == "sahnghao",
"The full-length candidate for 'sahnghao' must be marked.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
session.set_pinyin_sequence("sahng");
session.set_pinyin_sequence_with_cases("saHng");
session.recompute_candidates();
expect(session.get_pinyin_segmentation_with_cases() == "saHng",
"Uppercase input letters must survive the display rebuild.");
expect(!session.candidates().empty() && session.candidates().front().corrected_from == "sahng",
"Marking for cased input must carry the folded typed letters.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "shabg");
expect(session.get_pinyin_segmentation_with_cases() == "shabg",
"A BFS-corrected input with untouched letters must redraw separators from the raw spans.");
expect(!session.candidates().empty() && session.candidates().front().word == "上" &&
session.candidates().front().corrected_from == "shabg",
"The BFS-corrected candidate for 'shabg' must be marked.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, none, true, true, true, paths);
type_display_session(session, "shabg");
expect(session.get_pinyin_segmentation_with_cases() == "sha'b'g",
"With both switches off the legacy greedy separators must stay.");
expect(count_marked(session.candidates()) == 0, "No corrected candidate exists with both switches off.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, none, true, true, true, paths);
type_display_session(session, "sahng");
expect(session.get_pinyin_segmentation_with_cases() == "sahng",
"Even with both switches off the alias rewrite must be undone in the preedit.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, transposition_only, true, true, true, paths);
type_display_session(session, "sahng");
expect(!session.candidates().empty() && session.candidates().front().word == "上" &&
session.candidates().front().corrected_from == "sahng",
"Transposition-only must correct 'sahng' end to end (AC2, mask must survive the session).");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, neighbor_only, true, true, true, paths);
type_display_session(session, "shabg");
expect(!session.candidates().empty() && session.candidates().front().word == "上" &&
session.candidates().front().corrected_from == "shabg",
"Neighbor-only must correct 'shabg' end to end (AC3, mask must survive the session).");
expect(session.get_pinyin_segmentation_with_cases() == "shabg",
"Neighbor-only 'shabg' preedit shows the typed letters.");
}
{
// 空基础切分的丢声母错拼(jian -> ian):会话层同样要走到纠错候选,
// 预编辑按不等长切分的 raw 区间重绘分隔(ian'du)。
metasequoia::InputSession session(SchemeType::Quanpin, all, true, true, true, paths);
type_display_session(session, "iandu");
expect(session.get_pinyin_segmentation_with_cases() == "ian'du",
"A dropped initial must redraw separators from the raw spans (ian'du).");
expect(!session.candidates().empty() && session.candidates().front().word == "监督" &&
session.candidates().front().corrected_from == "iandu",
"An InputSession-level dropped initial must reach the corrected candidate.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, transposition_only, true, true, true, paths);
type_display_session(session, "shabg");
expect(count_marked(session.candidates()) == 0,
"Transposition-only must not correct the neighbor case 'shabg' (AC3).");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, neighbor_only, true, true, true, paths);
type_display_session(session, "sahng");
// 邻键开关关不掉别名层(现状基线),sahng 仍由别名层给出「上」并标记。
expect(session.get_pinyin_segmentation_with_cases() == "sahng",
"Neighbor-only 'sahng' preedit still shows the typed letters.");
expect(!session.candidates().empty() && session.candidates().front().word == "上" &&
session.candidates().front().corrected_from == "sahng",
"Neighbor-only keeps the alias-layer baseline for 'sahng'.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "keneng");
expect(session.get_pinyin_segmentation_with_cases() == "ke'neng",
"A legal spelling must keep its exact preedit.");
expect(count_marked(session.candidates()) == 0, "A legal spelling must produce no marks.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "xi'an");
expect(session.get_pinyin_segmentation_with_cases() == "xi'an",
"A manual delimiter input must keep its exact preedit.");
expect(count_marked(session.candidates()) == 0, "A manual delimiter input must produce no marks.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "zheg");
expect(session.get_pinyin_segmentation_with_cases() == "zhe'g",
"The jianpin shape 'zheg' must keep its raw letters and separator.");
expect(count_marked(session.candidates()) == 0, "The jianpin shape 'zheg' must produce no marks.");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "wj");
expect(session.get_pinyin_segmentation_with_cases() == "w'j", "Pure jianpin must keep its greedy preedit.");
expect(count_marked(session.candidates()) == 0, "Pure jianpin must produce no marks (AC5).");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "nv");
expect(session.get_pinyin_segmentation_with_cases() == "nv",
"The u-umlaut spelling must keep its exact preedit.");
expect(!session.candidates().empty() && session.candidates().front().word == "女" &&
session.candidates().front().corrected_from.empty(),
"The u-umlaut candidate must stay unmarked.");
}
{
// 阶段 3:deletion 位随既有开关联动(design D2)后,显式三位 mask 下
// zheg 的 preedit 必须仍走简拼守卫,不被漏字表重排为 zheng。
const unsigned all_three = transposition_only | neighbor_only | quanpin::kAutocorrectDeletion;
metasequoia::InputSession session(SchemeType::Quanpin, all_three, true, true, true, paths);
type_display_session(session, "zheg");
expect(session.get_pinyin_segmentation_with_cases() == "zhe'g",
"The jianpin guard must hold with the deletion bit enabled (phase 3).");
expect(count_marked(session.candidates()) == 0,
"The jianpin shape must stay unmarked with the deletion bit enabled.");
}
{
// 阶段 3:漏字输入的 display —— 切分段携带不等长 raw span,rebuild 后
// preedit 显示原始字母(无分隔,单段纠错)。
metasequoia::InputSession session(SchemeType::Quanpin, both, true, true, true, paths);
type_display_session(session, "shng");
expect(session.get_pinyin_segmentation_with_cases() == "shng",
"A deletion-corrected input must show its typed letters in the preedit.");
expect(!session.candidates().empty() && session.candidates().front().word == "上" &&
session.candidates().front().corrected_from == "shng",
"The deletion reading must reach the candidates end to end with the linked bit.");
}
{
// 阶段 3(design D2):仅开 transposition 时请求布尔映射也带上 deletion
// 位 —— "shng" 必须可纠(端到端行为断言:mask 是会话内部状态,以纠错
// 生效为准)。
metasequoia::InputSession session(SchemeType::Quanpin, transposition_only, true, true, true, paths);
type_display_session(session, "shng");
expect(std::any_of(session.candidates().begin(), session.candidates().end(),
[](const WordItem &item) { return item.word == "上"; }),
"Transposition-only must still enable the deletion reading of 'shng' (D2 linkage).");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, none, true, true, true, paths);
type_display_session(session, "shng");
// 门控按纠错标记验证:前缀查询可能以 'sh' 命中「上」,那与纠错无关;
// 开关全关时不得存在任何带 corrected_from 的候选。
expect(std::none_of(session.candidates().begin(), session.candidates().end(),
[](const WordItem &item) { return !item.corrected_from.empty(); }),
"Both switches off must keep the deletion reading disabled.");
}
{
// 阶段 4:插入输入的 display —— raw span 比音节长 1,preedit 显示原始
// 字母(无分隔,单段纠错)。用开头双打 "sshang":结尾单插入属简拼尾
// 形状,被词典门拦截(见 zher 用例)。
const unsigned all_four =
transposition_only | neighbor_only | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
metasequoia::InputSession session(SchemeType::Quanpin, all_four, true, true, true, paths);
type_display_session(session, "sshang");
expect(session.get_pinyin_segmentation_with_cases() == "sshang",
"An insertion-corrected input must show its typed letters in the preedit.");
expect(!session.candidates().empty() && session.candidates().front().word == "上" &&
session.candidates().front().corrected_from == "sshang",
"The insertion reading must reach the candidates end to end.");
}
{
// 阶段 4(design D4):仅开 transposition 时请求布尔映射也带上 insertion
// 位 —— "sshang" 必须可纠(端到端行为断言)。
metasequoia::InputSession session(SchemeType::Quanpin, transposition_only, true, true, true, paths);
type_display_session(session, "sshang");
expect(std::any_of(session.candidates().begin(), session.candidates().end(),
[](const WordItem &item) { return item.word == "上"; }),
"Transposition-only must still enable the insertion reading of 'sshang' (D4 linkage).");
}
{
// 阶段 4:'zher' 是 insertion 键(r 是 e 的邻键)但属简拼守卫形状,
// 词典门在 BFS 之前拦截:preedit 保持 zhe'r,无任何纠错标记(AC2)。
const unsigned all_four =
transposition_only | neighbor_only | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
metasequoia::InputSession session(SchemeType::Quanpin, all_four, true, true, true, paths);
type_display_session(session, "zher");
// 守卫拦下纠错后,preedit 保持方案层自身的贪心切分(zh + er),
// 不重绘为 insertion 键的字母区间。
expect(session.get_pinyin_segmentation_with_cases() == "zh'er",
"The jianpin guard must hold for 'zher' with the insertion bit enabled.");
expect(count_marked(session.candidates()) == 0,
"The jianpin shape 'zher' must stay unmarked (insertion key inside the guard). ");
}
{
metasequoia::InputSession session(SchemeType::Quanpin, none, true, true, true, paths);
type_display_session(session, "sshang");
expect(std::none_of(session.candidates().begin(), session.candidates().end(),
[](const WordItem &item) { return !item.corrected_from.empty(); }),
"Both switches off must keep the insertion reading disabled.");
}
fs::remove_all(session_dir, cleanup_ec);
fs::remove(db_path, cleanup_ec);
}
int main(int argc, char *argv[])
{
try
{
test_word_lattice();
test_shuangpin_session();
test_shuangpin_session02();
test_quanpin_session();
test_dynamic_switch();
test_quanpin_session_backspace();
test_shuangpin_session_backspace();
test_shuangpin_manual_apostrophe();
test_quanpin_dictionary_backspace();
test_shuangpin_dictionary_backspace();
test_shuangpin_dictionary_create_pin_delete();
test_shuangpin_dictionary_create_pin_delete_three_syllables();
test_shuangpin_query_manual_apostrophe();
test_quanpin_order_corrections();
test_quanpin_four_syllable_alternative_segmentation();
test_quanpin_single_letter_jianpin_ranking();
test_quanpin_query_timings();
test_quanpin_autocorrect_switches_and_guard();
test_quanpin_autocorrect_display();
fmt::println("All tests passed.");
return 0;
}
catch (const std::exception &ex)
{
fmt::println(stderr, "Test failure: {}", ex.what());
return 1;
}
}