#include #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 "test_directory_cleanup.h" #include #include #include #include #include #include #include #include #include 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 words(const InputSession &session) { std::vector result; result.reserve(session.candidates().size()); for (const auto &candidate : session.candidates()) { result.push_back(candidate.word); } return result; } std::vector 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 and wq in wubi, 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, which is how the first version of this file passed here and // failed on every CI platform. 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);" "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 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);"); 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++); }; // A code the wubi table cannot answer is answered by quanpin for the same letters. { 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 wubi."); 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 code the table does answer keeps its own candidates, untouched by the setting. { InputSession plain(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); plain.set_wubi_input_options(WubiInputOptions{false}); const auto native = type(plain, "wq"); require(!native.empty(), "The fixture did not answer wq in wubi."); InputSession mixed(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); mixed.set_wubi_input_options(WubiInputOptions{true}); require(type(mixed, "wq") == native, "Mixed input changed the candidates for a matched code."); } // Per-key prefix hints are not an answer: ta has no row of its own (only taaa starts with it), // so mixed input still offers pinyin, while plain wubi keeps showing the hints. { InputSession plain(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); plain.set_wubi_input_options(WubiInputOptions{false}); const auto hints = type(plain, "ta"); require(std::find(hints.begin(), hints.end(), "笔") != hints.end(), "Plain wubi did not show the prefix hint for ta."); InputSession mixed(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); mixed.set_wubi_input_options(WubiInputOptions{true}); const auto candidates = type(mixed, "ta"); require(std::find(candidates.begin(), candidates.end(), "他") != candidates.end(), "Prefix hints kept mixed input from offering pinyin for ta."); require(mixed.answered_by_pinyin_fallback(), "ta was not answered by the pinyin fallback."); } // 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."); } // Only a complete four-letter code the table answered with exactly one candidate is a // unique wubi code: shorter codes are incomplete, a recoded four-letter code is not unique, // and a four-letter spelling only the pinyin fallback answers is not a wubi code at all. { 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 pinyin-fallback guard has to reject it on its own. InputSession fallback(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); fallback.set_wubi_input_options(WubiInputOptions{true}); const auto fallback_candidates = type(fallback, "xiao"); require(fallback_candidates.size() == 1, "The fixture did not answer xiao with one candidate."); require(fallback.answered_by_pinyin_fallback(), "xiao was not answered by the pinyin fallback."); require(!fallback.wubi_unique_four_code(), "A pinyin-fallback answer was called a unique wubi code."); require(!fallback.wubi_four_code_is_complete(), "A pinyin-fallback answer was reported as a complete wubi code."); InputSession long_spelling(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); long_spelling.set_wubi_input_options(WubiInputOptions{true}); type(long_spelling, "nihao"); require(!long_spelling.wubi_unique_four_code(), "A mixed-input spelling was called a complete code."); require(!long_spelling.wubi_four_code_is_complete(), "A mixed-input spelling was reported as a complete code."); // Four letters no table row matched: the table did not answer, so there is no candidate // to commit and the code is not complete in the "answered by the table" sense. InputSession unknown_code(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); unknown_code.set_wubi_input_options(WubiInputOptions{false}); type(unknown_code, "wqac"); require(!unknown_code.wubi_four_code_is_complete(), "A four-letter code with no table candidate was reported as complete."); } // z is not a wubi letter. Dropping it does not refuse a spelling, it silently becomes a // different one, so mixed input has to accept it -- and only mixed input. { 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}); 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."); } // 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."); } // Committing a spelling out of a longer one leaves the rest composing, and the rest stays // with pinyin: wq is a code the wubi table knows, and answering it with wubi would swap // schemes underneath a spelling the user is still in the middle of. { const auto tail = [&](SchemeType scheme, bool mixed) { InputSession session(scheme, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); session.set_wubi_input_options(WubiInputOptions{mixed}); type(session, "nihaowq"); const auto &items = session.candidates(); const auto found = std::find_if(items.begin(), items.end(), [](const auto &item) { return item.word == "你好"; }); require(found != items.end(), "nihaowq did not offer 你好."); session.select_candidate(static_cast(found - items.begin())); return std::make_pair(session.preedit(), words(session)); }; const auto pinyin = tail(SchemeType::Quanpin, false); const auto mixed = tail(SchemeType::Wubi, true); require(mixed.first == pinyin.first, "Mixed input dropped the rest of the composition on selection."); require(mixed.second == pinyin.second, "The rest of a composition was answered with wubi candidates."); } // Off unless asked for, and the setting reaches a live composition rather than the next one. { InputSession session(SchemeType::Wubi, GetXiaoheShuangpinProfile(), paths_for(resources, root, next())); session.set_wubi_input_options(WubiInputOptions{false}); require(type(session, "nihao").empty(), "Mixed input answered without being switched on."); session.set_wubi_input_options(WubiInputOptions{true}); require(!words(session).empty(), "Switching the setting on left the composition unanswered."); session.set_wubi_input_options(WubiInputOptions{false}); require(words(session).empty(), "Switching the setting off left the fallback candidates on screen."); } // The public session carries the setting from its options and at runtime. { 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."); 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."); require(toggled.snapshot().answered_by_pinyin_fallback, "The snapshot did not report that pinyin answered the code."); // A host that acts on candidate counts has to tell these apart: four letters answered by // one pinyin word is not the unique four-code wubi candidate that auto-commit looks for. 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().answered_by_pinyin_fallback, "A code the wubi table answered was reported as a pinyin fallback."); } } catch (const std::exception &error) { std::fprintf(stderr, "%s\n", error.what()); return 1; } return 0; }