Files
msime-windows/engine/tests/src/test_pinyin.cpp
T
fanlusky e7f7addd4f feat(engine): 神经网络整句联想与词格 n-best 重排
- 新增 engine/neural:移植 chinese-ime-lm 的字符级 Transformer 推理,在词格解出的最多 12 条
  n-best 里重排挑选整句,desktop / keyboard 两档模型各出一条首选,后台 worker 异步打分
- 词格支持 n-best 输出与重排(quanpin/lattice_rerank),全拼、双拼共用
- 新增 SentenceAssociationOptions,经 QueryRequest 传给各 provider;四个来源默认全关
- 资源契约登记两个 safetensors 与 NOTICE;模型二进制不进仓库,由
  scripts/fetch-neural-model.ps1 按 neural-model/lock.json 下载并校验 SHA256
- 新增 neural_sentence 测试
2026-09-24 14:03:39 +08:00

1701 lines
93 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 直接返回空串;sc.lm 缺失则退回启发式
// 打分,整句候选只是变差、不会消失。所以这里仅在源目录存在时才拷贝。
for (const auto &file_name : {"msime.db", "dict_pinyin.dat", "user_dict.dat", "sc.lm"})
{
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'.");
}
// 两条整句来源的相对次序:词格(Generated,kenlm 打分)必须排在 Google 解码器
// (Fallback)之前。merge_lattice_candidates 自己的插入点已经由上面的表驱动用例
// 覆盖,但词典层曾经在合并之后又把 Fallback 提回首位,那一步没有任何测试盯着,
// 换掉打分模型之后才发现次序是反的。这里断的是词典层的最终结果。
//
// 不写死具体句子:词格出什么取决于 msime.db 和 sc.lm,钉死了会随词库更新而碎。
// 断的是相对位置,且只在两条来源都真的出现时才断。
void test_quanpin_lattice_precedes_google_fallback()
{
QuanpinDictionary dictionary;
fmt::println("==== Quanpin Lattice Precedes Google Fallback ====");
bool observed = false;
for (const auto *query : {"nihaoshijie", "jintiantianqizhenhao", "womenyiqiquchifan"})
{
const auto result = dictionary.query(query);
const auto generated = std::find_if(result.begin(), result.end(), [](const WordItem &item) {
return item.source == CandidateSource::Generated;
});
const auto fallback = std::find_if(result.begin(), result.end(), [](const WordItem &item) {
return item.source == CandidateSource::Fallback;
});
if (generated == result.end() || fallback == result.end())
continue;
observed = true;
expect(generated < fallback,
fmt::format("Expected the lattice sentence ahead of the Google fallback for '{}', got '{}' before '{}'",
query, fallback->word, generated->word));
}
if (!observed)
{
// sc.lm 或 dict_pinyin.dat 不在数据目录里时两条来源凑不齐,与其他用例一样优雅跳过。
fmt::println("Skipped: no query produced both a lattice and a Google fallback sentence.");
}
}
// 多音字的生僻读音不能被词格整句选中。词库里「卷」带一行 gun、「而」带一行
// neng,权重都是 0;kenlm 只看汉字,这两个字它见得多,于是 gun'qi 出过「卷七」、
// neng'fa'sheng 出过「而发生」。WordLatticeOptions::reading_prior 按读音占比补上
// 这一维。依赖真实 msime.db + sc.lm,凑不齐时与其他用例一样优雅跳过。
void test_quanpin_lattice_rejects_rare_readings()
{
fmt::println("==== Quanpin Lattice Rejects Rare Readings ====");
// 表驱动的词格用例跑的是无模型的启发式打分,那条路本来就按绝对权重压生僻行,
// 出不了这个 bug。所以这里直接拿真实 msime.db + sc.lm 解一次词格。
const auto paths = metasequoia::RuntimePaths::legacy();
const auto &model = ngram::shared_language_model(paths.resource(metasequoia::assets::language_model));
const std::string db_path = quanpin::get_default_db_path();
sqlite3 *db = nullptr;
if (!model.valid() || sqlite3_open_v2(db_path.c_str(), &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK)
{
if (db != nullptr)
sqlite3_close(db);
fmt::println("Skipped: sc.lm or msime.db is not available.");
return;
}
std::unordered_map<std::string, sqlite3_stmt *> statement_cache;
for (const auto &probe : {std::pair<const char *, const char *>{"gun'qi", "卷七"},
std::pair<const char *, const char *>{"neng'fa'sheng", "而发生"}})
{
quanpin::WordLatticeOptions options;
options.nbest = 1;
options.language_model = &model;
const auto lattice_paths = quanpin::decode_word_lattice(
quanpin::split_segments(probe.first),
quanpin::make_lattice_db_lookup(db, statement_cache, options.span_limit), options);
const std::string sentence = lattice_paths.empty() ? "" : lattice_paths.front().sentence;
// 「卷」在词库里带一行 gun、「而」带一行 neng,权重都是 0;kenlm 只看汉字,
// 这两个字它见得多,reading_prior 之前 gun'qi 出「卷七」、neng'fa'sheng
// 出「而发生」。
expect(sentence != probe.second,
fmt::format("'{}' spells a rare reading and must not win the lattice for '{}'.", probe.second,
probe.first));
}
for (auto &entry : statement_cache)
sqlite3_finalize(entry.second);
sqlite3_close(db);
}
// 词格的跨度查询只认精确键,所以每个合法音节在词库里都必须至少查得到一行。少一个,
// 句子里一旦用到它,整张词格就断了——不是候选变差,是长句联想整个消失。切分器认
// jv / lue 这类 ü 的另一种拼法,而词库只存 ju / lve,曾经就是这么断的。这条用例把
// 「切分器认的音节集」和「词库存的键」钉在一起,任何一边漂了都会在这里先炸。
void test_quanpin_lattice_covers_every_syllable()
{
fmt::println("==== Quanpin Lattice Covers Every Syllable ====");
const std::string db_path = quanpin::get_default_db_path();
sqlite3 *db = nullptr;
if (sqlite3_open_v2(db_path.c_str(), &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK)
{
if (db != nullptr)
sqlite3_close(db);
fmt::println("Skipped: msime.db is not available.");
return;
}
std::unordered_map<std::string, sqlite3_stmt *> statement_cache;
const auto lookup = quanpin::make_lattice_db_lookup(db, statement_cache, 32);
std::string uncovered;
size_t uncovered_count = 0;
for (const auto &syllable : quanpin::intact_pinyin_list())
{
if (!lookup({syllable}).empty())
continue;
if (uncovered_count++ > 0)
uncovered += ' ';
uncovered += syllable;
}
expect(uncovered_count == 0, fmt::format("Every syllable needs at least one exact-key row, but {} have none: {}.",
uncovered_count, uncovered));
for (auto &entry : statement_cache)
sqlite3_finalize(entry.second);
sqlite3_close(db);
}
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));
}
{
// 门槛是 2 个音节:两音节输入也出整句,但整句排在整串拼音精确命中的词库
// 候选之后,不许抢首位。
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", "捏子", 12000, CandidateSource::Database, "nie'zi");
quanpin::merge_lattice_candidates(candidates, {"nie", "zi"}, make_table_lattice_lookup(table), "nxzi");
expect(candidates.front().word == "捏子" && candidates.front().source == CandidateSource::Database,
fmt::format("Expected the exact Database hit to stay first, got '{}'", candidates.front().word));
expect(find_candidate(candidates, "镊子") != nullptr,
"Expected the two-syllable lattice sentence 镊子 to be merged in.");
}
{
// 单音节仍然不出整句:精确查表已经全覆盖。
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["zi"] = {{"zi", "子", 9000}, {"zi", "字", 8000}};
std::vector<WordItem> candidates;
candidates.emplace_back("zi", "子", 9000, CandidateSource::Database, "zi");
quanpin::merge_lattice_candidates(candidates, {"zi"}, make_table_lattice_lookup(table), "zi");
expect(candidates.size() == 1 && candidates.front().source == CandidateSource::Database,
"Single-syllable merge is a no-op; exact SQLite already covers the key.");
}
{
// generated_sentence_insert_position:跳过开头那串「键与输入完全相等」的
// 词库候选,前缀候选和非词库来源都不算,遇到就停。全拼/双拼词典层的 Google
// 整句用的是同一个位置,所以两条整句来源都落在词库短语之后。
const quanpin::Segments ni_hao{"ni", "hao"};
std::vector<WordItem> candidates;
candidates.emplace_back("ni'hao", "你好", 30000, CandidateSource::Database, "ni'hao");
candidates.emplace_back("ni'hao", "拟好", 9000, CandidateSource::UserDatabase, "ni'hao");
candidates.emplace_back("ni", "你", 50000, CandidateSource::Database, "ni");
expect(quanpin::generated_sentence_insert_position(candidates, ni_hao) == 2,
"Expected the insert position to sit right after the two exact-key hits.");
std::vector<WordItem> no_exact_hit;
no_exact_hit.emplace_back("ni", "你", 50000, CandidateSource::Database, "ni");
expect(quanpin::generated_sentence_insert_position(no_exact_hit, ni_hao) == 0,
"Expected a prefix-only list to take the sentence at the head.");
// 前缀区间扫描出来的行音节数、字数都和输入一样(gun'qi 捞出 gun'qiu 的
// 滚球),但键不等于输入,不许挡在整句前面。截图里「滚其」排到末尾就是
// 按音节数判断的后果。
const quanpin::Segments gun_qi{"gun", "qi"};
std::vector<WordItem> prefix_scan;
prefix_scan.emplace_back("gyqi", "滚球", 30000, CandidateSource::Database, "gun'qiu");
prefix_scan.emplace_back("gyqi", "滚起", 12000, CandidateSource::Database, "gun'qi");
expect(quanpin::generated_sentence_insert_position(prefix_scan, gun_qi) == 0,
"A prefix-range row with the same syllable count must not count as an exact hit.");
std::vector<WordItem> exact_then_prefix;
exact_then_prefix.emplace_back("gyqi", "滚起", 12000, CandidateSource::Database, "gun'qi");
exact_then_prefix.emplace_back("gyqi", "滚球", 30000, CandidateSource::Database, "gun'qiu");
expect(quanpin::generated_sentence_insert_position(exact_then_prefix, gun_qi) == 1,
"A leading exact-key hit still outranks the sentence; the prefix row behind it does not.");
}
{
// 整句只出各来源的首选,n-best 的其余部分是解码中间产物,不进候选区。
// 没有重排器时就一条;重排器改了主意时,把它的首选和词格的首选各留一条。
std::unordered_map<std::string, std::vector<LatticeLexeme>> table;
table["ni"] = {{"ni", "你", 12000}, {"ni", "拟", 3000}, {"ni", "泥", 2000}};
table["hao"] = {{"hao", "好", 12000}, {"hao", "号", 3000}, {"hao", "耗", 2000}};
std::vector<WordItem> alone;
quanpin::merge_lattice_candidates(alone, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao");
expect(alone.size() == 1, fmt::format("Expected a single lattice sentence, got {}", alone.size()));
// 重排器把第二条顶到首位:两条首选都要留下,重排的在前。
std::vector<WordItem> reranked;
quanpin::merge_lattice_candidates(
reranked, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao", {},
[](std::vector<quanpin::LatticePath> &paths) {
if (paths.size() >= 2)
std::swap(paths[0], paths[1]);
return true;
},
CandidateSource::NeuralDesktop);
expect(reranked.size() == 2,
fmt::format("Expected the reranker's pick and the lattice's, got {}", reranked.size()));
if (reranked.size() == 2 && alone.size() == 1)
{
expect(reranked.front().word != alone.front().word && reranked[1].word == alone.front().word,
"Expected the reranked pick first and the lattice's own pick behind it.");
// 来源要分得开:候选框据此标注是谁挑的。
expect(reranked.front().source == CandidateSource::NeuralDesktop &&
reranked[1].source == CandidateSource::Generated,
"Expected the reranker's pick tagged with its own source and the lattice's with Generated.");
}
// 重排器同意词格的排序时不该凭空多出一行,但来源要记在重排那边:模型看过并认可了它。
std::vector<WordItem> agreed;
quanpin::merge_lattice_candidates(
agreed, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao", {},
[](std::vector<quanpin::LatticePath> &) { return true; }, CandidateSource::NeuralDesktop);
expect(agreed.size() == 1,
fmt::format("A reranker that agrees should add no extra row, got {}", agreed.size()));
if (agreed.size() == 1)
{
expect(agreed.front().source == CandidateSource::NeuralDesktop,
"A reranker that ran and agreed should still be credited for the row.");
}
// 弃权(模型没加载、后台还没算完)与「跑了且同意」不是一回事:来源仍是词格。
std::vector<WordItem> declined;
quanpin::merge_lattice_candidates(
declined, {"ni", "hao"}, make_table_lattice_lookup(table), "ni'hao", {},
[](std::vector<quanpin::LatticePath> &) { return false; }, CandidateSource::NeuralDesktop);
expect(declined.size() == 1 && declined.front().source == CandidateSource::Generated,
"A reranker that declined must leave the row credited to the lattice.");
}
{
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.");
// 整句候选(词格 / Google 解码器)走的是同一条纠错读音,字母与该读音相同,
// 因此同样带上 corrected_from。整句门槛降到 2 个音节之后 shang'hao 就够格,
// 主切分和候选读音 shan'gua'o 各会多出整句,所以不再是「恰好一条」。这里断
// 的仍是原来的意图:被标记的必须覆盖整串输入,前缀候选一律不标。
const auto letter_count = [](const std::string &pinyin) {
return static_cast<size_t>(std::count_if(pinyin.begin(), pinyin.end(), [](char c) { return c != '\''; }));
};
expect(count_marked(full) >= 1, "The full-length corrected candidate must be marked for 'sahnghao'.");
const size_t typed_letters = letter_count("sahnghao");
expect(std::all_of(full.begin(), full.end(),
[&](const WordItem &item) {
return item.corrected_from.empty() || letter_count(item.pinyin) == typed_letters;
}),
"Only candidates covering the whole typed input 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_lattice_precedes_google_fallback();
test_quanpin_lattice_rejects_rare_readings();
test_quanpin_lattice_covers_every_syllable();
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;
}
}