- 首字母展开与固定位置补入的拼音候选不经过 query(),此前保留默认 Quanpin, 双拼会话里调频/删除会落到全拼引擎;现在按查询方案打标。 - 混输与 z 通配同时开启时,含 z 的编码拼音候选排在通配结果之前,避免通配 整页码行把 zi/zu 这类拼写的正解挤到后几页。 - 固定位置分区合并时,拼音分区 include_missing 补回的词若五笔分区已列出则去掉, 保持同词只留五笔一份。
407 lines
22 KiB
C++
407 lines
22 KiB
C++
#include <metasequoia/session.h>
|
|
|
|
#include "../../contracts/assets/assets.h"
|
|
#include "../../core/data_path.h"
|
|
#include "../../english/english_dictionary.h"
|
|
#include "../../core/input_session.h"
|
|
#include "../../core/runtime_paths.h"
|
|
#include "../../user_dictionary/user_dictionary_journal.h"
|
|
#include "test_directory_cleanup.h"
|
|
|
|
#include <sqlite3.h>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace
|
|
{
|
|
using namespace metasequoia;
|
|
|
|
void require(bool value, const char *message)
|
|
{
|
|
if (!value)
|
|
{
|
|
throw std::runtime_error(message);
|
|
}
|
|
}
|
|
|
|
void execute(const std::filesystem::path &path, const std::string &sql)
|
|
{
|
|
sqlite3 *database = nullptr;
|
|
require(sqlite3_open(path_to_utf8(path).c_str(), &database) == SQLITE_OK, "Fixture open failed");
|
|
const int result = sqlite3_exec(database, sql.c_str(), nullptr, nullptr, nullptr);
|
|
sqlite3_close(database);
|
|
require(result == SQLITE_OK, "Fixture SQL failed");
|
|
}
|
|
|
|
std::vector<std::string> words(const InputSession &session)
|
|
{
|
|
std::vector<std::string> result;
|
|
result.reserve(session.candidates().size());
|
|
for (const auto &candidate : session.candidates())
|
|
{
|
|
result.push_back(candidate.word);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The scheme that produced the candidate at `index`: the merge puts wubi rows first and appends
|
|
// the pinyin ones, so callers assert both the order and the owner.
|
|
SchemeType scheme_of(const InputSession &session, std::size_t index)
|
|
{
|
|
return session.candidates()[index].scheme;
|
|
}
|
|
|
|
std::size_t wubi_count(const InputSession &session)
|
|
{
|
|
return static_cast<std::size_t>(
|
|
std::count_if(session.candidates().begin(), session.candidates().end(),
|
|
[](const WordItem &item) { return item.scheme == SchemeType::Wubi; }));
|
|
}
|
|
|
|
std::vector<std::string> type(InputSession &session, const std::string &code)
|
|
{
|
|
for (const char letter : code)
|
|
{
|
|
session.handle_character(letter);
|
|
}
|
|
return words(session);
|
|
}
|
|
|
|
// The fixture answers ni'hao and zi in quanpin, ta and hao in both dictionaries, and leaves nihao
|
|
// unanswered by the wubi table. Hardcoding codes against the shipped dictionary would tie these
|
|
// assertions to what a checkout happens to have built.
|
|
std::filesystem::path prepare_resources(const std::filesystem::path &root)
|
|
{
|
|
const auto resources = root / "resources";
|
|
std::filesystem::create_directories(resources);
|
|
execute(resources / assets::main_dictionary,
|
|
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO tbl_1_n VALUES('ni','n','你',10000);"
|
|
"CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO tbl_1_z VALUES('zi','z','子',10000),('zu','z','组',10000);"
|
|
"CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',10000),('ni''hao','nh','拟好',9000);"
|
|
"CREATE TABLE tbl_1_x(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO tbl_1_x VALUES('xiao','x','笑',10000);"
|
|
"CREATE TABLE tbl_1_t(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO tbl_1_t VALUES('ta','t','他',10000);"
|
|
"CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO tbl_1_h VALUES('hao','h','好',9500),('hao','h','号',9000);"
|
|
"CREATE TABLE tbl_1_a(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO tbl_1_a VALUES('a','a','啊',9000),('a','a','工',100);"
|
|
"CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);"
|
|
"INSERT INTO wubi86 VALUES('wq','你好',10000),('wqaa','众人',9000),"
|
|
"('wqab','甲',8000),('wqab','乙',7000),('taaa','笔',6000),"
|
|
"('a','工',10000),('aaaa','工',5000),('aaab','苛',4000),"
|
|
"('hao','号',9000),('au','乐',20000);");
|
|
require(EnglishDictionary::ensure_schema(path_to_utf8(resources / assets::english_dictionary)),
|
|
"English schema failed");
|
|
return resources;
|
|
}
|
|
|
|
// InputSession is neither copyable nor movable, so a session is built where it is used.
|
|
RuntimePaths paths_for(const std::filesystem::path &resources, const std::filesystem::path &root,
|
|
const std::string &tag)
|
|
{
|
|
return prepare_runtime_paths(resources, root / ("user-" + tag), root / ("cache-" + tag), "v1");
|
|
}
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
try
|
|
{
|
|
const auto root =
|
|
std::filesystem::temp_directory_path() /
|
|
("msime-wubi-mixed-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
test::ScopedDataDirectoryCleanup cleanup(root);
|
|
const auto resources = prepare_resources(root);
|
|
int counter = 0;
|
|
const auto next = [&counter] { return std::to_string(counter++); };
|
|
|
|
// Off is pure wubi: an unmatched code stays empty, a prefix hint is all it shows.
|
|
{
|
|
InputSession plain(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
plain.set_wubi_input_options(WubiInputOptions{false});
|
|
require(type(plain, "nihao").empty(), "The fixture answered nihao in plain wubi.");
|
|
|
|
InputSession hints(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
hints.set_wubi_input_options(WubiInputOptions{false});
|
|
const auto ta = type(hints, "ta");
|
|
require(std::find(ta.begin(), ta.end(), "他") == ta.end(),
|
|
"Plain wubi offered pinyin for a code the table did not answer.");
|
|
require(std::find(ta.begin(), ta.end(), "笔") != ta.end(), "Plain wubi dropped the prefix hint.");
|
|
}
|
|
|
|
// Simultaneous mixed input: a code the wubi table knows keeps its rows first and the same
|
|
// letters are also offered to quanpin. A word both dictionaries answer is listed once.
|
|
{
|
|
InputSession mixed(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
mixed.set_wubi_input_options(WubiInputOptions{true});
|
|
const auto candidates = type(mixed, "hao");
|
|
require(candidates.size() == 2, "The fixture did not answer hao with both dictionaries.");
|
|
require(candidates[0] == "号" && candidates[1] == "好",
|
|
"Mixed input did not put the wubi candidate first.");
|
|
require(scheme_of(mixed, 0) == SchemeType::Wubi, "The exact code row was not tagged as wubi.");
|
|
require(scheme_of(mixed, 1) == SchemeType::Quanpin, "The appended candidate was not tagged as pinyin.");
|
|
require(std::count(candidates.begin(), candidates.end(), "号") == 1,
|
|
"A word answered by both dictionaries was listed twice.");
|
|
require(wubi_count(mixed) == 1, "The wubi candidate count was wrong.");
|
|
}
|
|
|
|
// The table failing the code does not drop its prefix hints: they stay in front of the
|
|
// pinyin candidates, which is what "candidates prefer wubi" means for a partial code.
|
|
{
|
|
InputSession mixed(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
mixed.set_wubi_input_options(WubiInputOptions{true});
|
|
const auto candidates = type(mixed, "ta");
|
|
const auto hint = std::find(candidates.begin(), candidates.end(), "笔");
|
|
const auto pinyin = std::find(candidates.begin(), candidates.end(), "他");
|
|
require(hint != candidates.end(), "Mixed input dropped the wubi prefix hint.");
|
|
require(pinyin != candidates.end(), "Mixed input did not offer pinyin for ta.");
|
|
require(hint < pinyin, "Mixed input put the pinyin candidate before the wubi hint.");
|
|
require(wubi_count(mixed) == 1, "A prefix hint alone was counted as the code being answered.");
|
|
}
|
|
|
|
// A code only quanpin answers still reaches the user through the usual pinyin path.
|
|
{
|
|
InputSession mixed(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
mixed.set_wubi_input_options(WubiInputOptions{true});
|
|
InputSession reference(SchemeType::Quanpin, GetXiaoheShuangpinProfile(),
|
|
paths_for(resources, root, next()));
|
|
reference.set_wubi_input_options(WubiInputOptions{false});
|
|
require(type(mixed, "nihao") == type(reference, "nihao"),
|
|
"Mixed input answered an unmatched code with something other than quanpin.");
|
|
}
|
|
|
|
// A word with both a short and a full code appears once, under the code actually typed.
|
|
{
|
|
InputSession session(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
session.set_wubi_input_options(WubiInputOptions{false});
|
|
const auto candidates = type(session, "a");
|
|
require(std::count(candidates.begin(), candidates.end(), "工") == 1,
|
|
"A word with a short and a full code was listed twice.");
|
|
require(session.candidates().front().word == "工" && session.candidates().front().pinyin == "a",
|
|
"The deduplicated word did not keep the exact-code row.");
|
|
}
|
|
|
|
// Auto-commit facts count only the code's own rows: a pinyin spelling mixed input appends
|
|
// is not a wubi candidate, and a recoded four-letter code is complete but not unique.
|
|
{
|
|
InputSession unique(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
unique.set_wubi_input_options(WubiInputOptions{false});
|
|
const auto unique_candidates = type(unique, "wqaa");
|
|
require(unique_candidates.size() == 1, "The fixture did not answer wqaa with one candidate.");
|
|
require(unique.wubi_unique_four_code(), "A unique four-letter wubi code was not reported as one.");
|
|
require(unique.wubi_four_code_is_complete(), "A complete wubi code was not reported as complete.");
|
|
|
|
InputSession recoded(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
recoded.set_wubi_input_options(WubiInputOptions{false});
|
|
require(type(recoded, "wqab").size() == 2, "The fixture did not recode wqab.");
|
|
require(!recoded.wubi_unique_four_code(), "A recoded four-letter wubi code was called unique.");
|
|
// Completeness is deliberately not uniqueness: the recoded code still commits its first
|
|
// candidate when the user types past the fourth letter instead of losing that letter.
|
|
require(recoded.wubi_four_code_is_complete(),
|
|
"A recoded four-letter wubi code was not reported as complete.");
|
|
|
|
InputSession short_code(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
short_code.set_wubi_input_options(WubiInputOptions{false});
|
|
type(short_code, "wq");
|
|
require(!short_code.wubi_unique_four_code(), "A two-letter code was called a complete wubi code.");
|
|
require(!short_code.wubi_four_code_is_complete(),
|
|
"A two-letter code was reported as a complete wubi code.");
|
|
|
|
// The fixture gives xiao exactly one quanpin candidate, so candidate count alone would
|
|
// call it unique: the wubi-native guard has to reject the appended pinyin row on its own.
|
|
InputSession pinyin_only(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
pinyin_only.set_wubi_input_options(WubiInputOptions{true});
|
|
const auto pinyin_candidates = type(pinyin_only, "xiao");
|
|
require(pinyin_candidates.size() == 1, "The fixture did not answer xiao with one candidate.");
|
|
require(pinyin_only.candidates().front().scheme == SchemeType::Quanpin,
|
|
"A pinyin-only answer was tagged as wubi.");
|
|
require(!pinyin_only.wubi_unique_four_code(), "A pinyin-only answer was called a unique wubi code.");
|
|
require(!pinyin_only.wubi_four_code_is_complete(),
|
|
"A pinyin-only answer was reported as a complete wubi code.");
|
|
|
|
InputSession mixed_unique(SchemeType::Wubi, GetXiaoheShuangpinProfile(),
|
|
paths_for(resources, root, next()));
|
|
mixed_unique.set_wubi_input_options(WubiInputOptions{true});
|
|
require(type(mixed_unique, "wqaa").size() == 1,
|
|
"The fixture answered wqaa with more than the one wubi row.");
|
|
require(mixed_unique.wubi_unique_four_code(), "A unique wubi code was not reported unique in mixed input.");
|
|
}
|
|
|
|
// z is a pinyin letter in mixed input and a wildcard only in wildcard mode -- two
|
|
// independent settings, never a single tri-state.
|
|
{
|
|
InputSession plain(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
plain.set_wubi_input_options(WubiInputOptions{false});
|
|
type(plain, "zi");
|
|
require(plain.preedit() == "i", "Plain wubi stopped dropping z.");
|
|
|
|
InputSession mixed(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
mixed.set_wubi_input_options(WubiInputOptions{true, false});
|
|
const auto candidates = type(mixed, "zi");
|
|
require(mixed.preedit() == "zi", "Mixed input dropped the z from a spelling.");
|
|
require(std::find(candidates.begin(), candidates.end(), "子") != candidates.end(),
|
|
"Mixed input did not reach the spelling that needed z.");
|
|
|
|
// Mixed input reads z as an ordinary pinyin letter, not as a wildcard.
|
|
InputSession mixed_z(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
mixed_z.set_wubi_input_options(WubiInputOptions{true, false});
|
|
require(type(mixed_z, "wz").empty(), "Mixed input read z as a wildcard.");
|
|
|
|
InputSession wildcard(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
wildcard.set_wubi_input_options(WubiInputOptions{false, true});
|
|
require(type(wildcard, "zi").empty(), "Wildcard mode answered zi without a z in the fixture code.");
|
|
require(wildcard.preedit() == "zi", "Wildcard mode dropped the z instead of keeping it.");
|
|
// wz matches wq (and longer codes starting with it) by standing for q.
|
|
InputSession match(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
match.set_wubi_input_options(WubiInputOptions{false, true});
|
|
const auto matched = type(match, "wz");
|
|
require(std::find(matched.begin(), matched.end(), "你好") != matched.end(),
|
|
"The wildcard code wz did not match the code wq.");
|
|
// Wildcard keeps the four-letter limit: mixed input is what lifts it, and this mode
|
|
// does not have it.
|
|
InputSession limit(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
limit.set_wubi_input_options(WubiInputOptions{false, true});
|
|
type(limit, "wqaa");
|
|
limit.handle_character('a');
|
|
require(limit.preedit() == "wqaa", "Wildcard mode accepted a fifth letter.");
|
|
|
|
// With both on, a z code is also a pinyin spelling: the wildcard rows (zu matches au) are
|
|
// guesses and must not push the spelling's answer off the first slot.
|
|
InputSession both(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
both.set_wubi_input_options(WubiInputOptions{true, true});
|
|
const auto both_candidates = type(both, "zu");
|
|
require(!both_candidates.empty() && both_candidates.front() == "组",
|
|
"Wildcard rows were ranked ahead of the pinyin spelling in mixed input.");
|
|
require(std::find(both_candidates.begin(), both_candidates.end(), "乐") != both_candidates.end(),
|
|
"Mixed input dropped the wildcard rows instead of ranking them after pinyin.");
|
|
}
|
|
|
|
// A pinyin word pinned under the pinyin context is pulled back in by include_missing; when the
|
|
// wubi half already lists that word it must still appear only once.
|
|
{
|
|
const auto paths = paths_for(resources, root, next());
|
|
InputSession session(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths);
|
|
session.set_wubi_input_options(WubiInputOptions{true});
|
|
require(
|
|
user_dictionary::set_fixed_position(path_to_utf8(paths.user(assets::user_journal)), "a", "a", "工", 1),
|
|
"Pinning the fixture word failed.");
|
|
session.enable_fixed_positions();
|
|
const auto candidates = type(session, "a");
|
|
require(std::count(candidates.begin(), candidates.end(), "工") == 1,
|
|
"A pinned pinyin word duplicated the same word from the wubi table.");
|
|
}
|
|
|
|
// Pinyin candidates that bypass query() still carry their scheme, so a shuangpin session's
|
|
// learning and removal reach the shuangpin engine instead of the default quanpin one.
|
|
{
|
|
InputSession shuangpin(SchemeType::Shuangpin, GetXiaoheShuangpinProfile(),
|
|
paths_for(resources, root, next()));
|
|
const auto found = shuangpin.find_candidate("ni", "你");
|
|
require(found.has_value(), "The fixture did not find ni in shuangpin.");
|
|
require(found->scheme == SchemeType::Shuangpin, "A looked-up shuangpin candidate was tagged as quanpin.");
|
|
|
|
type(shuangpin, "n");
|
|
if (shuangpin.expand_initial_candidates())
|
|
{
|
|
require(std::all_of(shuangpin.candidates().begin(), shuangpin.candidates().end(),
|
|
[](const WordItem &item) { return item.scheme == SchemeType::Shuangpin; }),
|
|
"An expanded shuangpin candidate was tagged as quanpin.");
|
|
}
|
|
}
|
|
|
|
// The four-letter limit holds until the table has failed the code in hand.
|
|
{
|
|
InputSession matched(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
matched.set_wubi_input_options(WubiInputOptions{true});
|
|
const auto four = type(matched, "wqaa");
|
|
require(!four.empty(), "The fixture did not answer the four-letter code wqaa.");
|
|
matched.handle_character('a');
|
|
require(matched.preedit() == "wqaa", "A matched four-letter code accepted a fifth letter.");
|
|
require(words(matched) == four, "A refused fifth letter still changed the candidates.");
|
|
|
|
InputSession unmatched(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next()));
|
|
unmatched.set_wubi_input_options(WubiInputOptions{true});
|
|
type(unmatched, "nihao");
|
|
require(unmatched.preedit() == "nihao", "Mixed input could not reach a spelling past four letters.");
|
|
unmatched.handle_command(Command::Backspace);
|
|
require(unmatched.preedit() == "niha", "Backspace did not shorten an extended composition.");
|
|
}
|
|
|
|
// The public session carries the setting from its options and at runtime, and tags the
|
|
// mixed candidates so a host can tell which dictionary answered.
|
|
{
|
|
SessionOptions options;
|
|
options.paths = prepare_runtime_paths(resources, root / "user-public", root / "cache-public", "v1");
|
|
options.scheme = SchemeType::Wubi;
|
|
options.wubi.mixed_pinyin = true;
|
|
Session session(options);
|
|
for (const char letter : std::string("nihao"))
|
|
{
|
|
session.character(letter);
|
|
}
|
|
require(!session.snapshot().candidates.empty(), "SessionOptions did not carry the mixed setting.");
|
|
require(session.snapshot().candidates.front().scheme == SchemeType::Quanpin,
|
|
"A pinyin-only answer was not tagged as pinyin.");
|
|
|
|
SessionOptions plain;
|
|
plain.paths = prepare_runtime_paths(resources, root / "user-toggle", root / "cache-toggle", "v1");
|
|
plain.scheme = SchemeType::Wubi;
|
|
Session toggled(plain);
|
|
for (const char letter : std::string("nihao"))
|
|
{
|
|
toggled.character(letter);
|
|
}
|
|
require(toggled.snapshot().candidates.empty(), "Mixed input was on without being asked for.");
|
|
toggled.set_wubi_mixed_pinyin(true);
|
|
require(!toggled.snapshot().candidates.empty(), "set_wubi_mixed_pinyin did not reach the composition.");
|
|
|
|
SessionOptions native;
|
|
native.paths = prepare_runtime_paths(resources, root / "user-native", root / "cache-native", "v1");
|
|
native.scheme = SchemeType::Wubi;
|
|
native.wubi.mixed_pinyin = true;
|
|
Session matched(native);
|
|
for (const char letter : std::string("wqaa"))
|
|
{
|
|
matched.character(letter);
|
|
}
|
|
require(!matched.snapshot().candidates.empty(), "The four-letter code answered with nothing.");
|
|
require(matched.snapshot().candidates.front().scheme == SchemeType::Wubi,
|
|
"A code the wubi table answered was not tagged as wubi.");
|
|
|
|
// set_wubi_mixed_pinyin must leave the sibling z-wildcard setting alone: a host that
|
|
// enabled wildcard at construction and toggles mixed at runtime keeps wildcard working.
|
|
SessionOptions wildcard;
|
|
wildcard.paths = prepare_runtime_paths(resources, root / "user-z", root / "cache-z", "v1");
|
|
wildcard.scheme = SchemeType::Wubi;
|
|
wildcard.wubi.z_wildcard = true;
|
|
Session z_session(wildcard);
|
|
z_session.set_wubi_mixed_pinyin(true);
|
|
for (const char letter : std::string("wz"))
|
|
{
|
|
z_session.character(letter);
|
|
}
|
|
const auto &z_candidates = z_session.snapshot().candidates;
|
|
require(std::any_of(z_candidates.begin(), z_candidates.end(),
|
|
[](const WordItem &item) { return item.word == "你好"; }),
|
|
"Toggling mixed pinyin reset the z wildcard setting.");
|
|
}
|
|
}
|
|
catch (const std::exception &error)
|
|
{
|
|
std::fprintf(stderr, "%s\n", error.what());
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|