#include #include "../../core/data_path.h" #include "../../core/input_session.h" #include "../../core/pinyin_decoder.h" #include "../../japanese/japanese_sentence_decoder.h" #include "../../contracts/assets/assets.h" #include "../../user_dictionary/user_dictionary_journal.h" #include "test_directory_cleanup.h" #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 *db = nullptr; if (sqlite3_open(path_to_utf8(path).c_str(), &db) != SQLITE_OK) throw std::runtime_error("fixture open failed"); const int result = sqlite3_exec(db, sql.c_str(), nullptr, nullptr, nullptr); const std::string error = sqlite3_errmsg(db); sqlite3_close(db); if (result != SQLITE_OK) throw std::runtime_error(error); } std::string bytes(const std::filesystem::path &path) { std::ifstream input(path, std::ios::binary); return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; } void make_japanese_model(const std::filesystem::path &path, const std::string &surface) { // One-token MSJPDT1 fixture, with explicit little-endian fields rather than native packing. std::ofstream output(path, std::ios::binary); const auto integer = [&](std::uint64_t value, int width) { for (int i = 0; i < width; ++i) output.put(static_cast((value >> (i * 8)) & 255)); }; const std::string reading = "かな"; output.write("MSJPDT1", 8); integer(1, 4); integer(1, 4); integer(1, 4); integer(0, 4); integer(56, 8); integer(76, 8); integer(78, 8); integer(reading.size() + surface.size(), 8); integer(0, 4); integer(reading.size(), 2); integer(reading.size(), 4); integer(surface.size(), 2); integer(0, 2); integer(0, 2); integer(1, 4); integer(0, 2); output << reading << surface; } void make_resources(const std::filesystem::path &path, const std::string &word) { std::filesystem::create_directories(path / "helpcodes"); execute(path / assets::main_dictionary, "CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);" "INSERT INTO tbl_1_n VALUES('ni','n','" + word + "',10000),('ni','n','拟',9000);" "CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);" "INSERT INTO quick_parases VALUES('x','" + word + "短语',10);"); require(EnglishDictionary::ensure_schema(path_to_utf8(path / assets::english_dictionary)), "English fixture failed"); execute(path / assets::english_dictionary, "INSERT INTO english_words(word,display,weight) VALUES('hello','hello',10);"); std::ofstream(path / assets::translations) << "hello\t" << word << "翻译\n"; std::ofstream(path / assets::helpcode_lantian) << word << "=aa\n拟=cc\n"; std::ofstream(path / assets::helpcode_xiaohe) << word << "=cc\n拟=aa\n"; } void type(Session &session, const std::string &input) { for (char ch : input) session.character(ch); } } // namespace void test_runtime_isolation() { using namespace metasequoia; const auto root = std::filesystem::temp_directory_path() / ("msime-runtime-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); test::ScopedDataDirectoryCleanup cleanup(root); const auto resource_a = root / std::filesystem::u8path("资源甲"); const auto resource_b = root / std::filesystem::u8path("资源乙"); make_resources(resource_a, "你"); make_resources(resource_b, "妮"); make_japanese_model(resource_a / assets::japanese_model, "甲"); make_japanese_model(resource_b / assets::japanese_model, "乙"); { const auto path = root / "mapped-japanese.dat"; make_japanese_model(path, "甲"); japanese::JapaneseSentenceDecoder original(path_to_utf8(path)); require(original.ready() && original.ExactLemmas("かな").front().surface == "甲", "Japanese model mapping did not load"); const auto replacement = root / "replacement-japanese.dat"; make_japanese_model(replacement, "乙"); // Publishers replace files, never truncate mapped inodes in place. std::filesystem::remove(path); std::filesystem::rename(replacement, path); japanese::JapaneseSentenceDecoder updated(path_to_utf8(path)); require(updated.ready() && updated.ExactLemmas("かな").front().surface == "乙" && original.ExactLemmas("かな").front().surface == "甲", "Replacing a model altered an existing decoder"); const auto valid = bytes(path); const auto bad = root / "invalid-japanese.dat"; const auto rejected = [&](const std::string &data) { { std::ofstream output(bad, std::ios::binary); output.write(data.data(), data.size()); } japanese::JapaneseSentenceDecoder decoder(path_to_utf8(bad)); require(!decoder.ready() && decoder.ExactLemmas("かな").empty(), "Invalid Japanese model accepted"); }; for (size_t length : {size_t(0), size_t(55), size_t(77), valid.size() - 1}) rejected(valid.substr(0, length)); auto invalid_offset = valid; invalid_offset.replace(24, 8, 8, char(0xff)); rejected(invalid_offset); auto invalid_reading = valid; invalid_reading[60] = char(0xff); invalid_reading[61] = char(0xff); rejected(invalid_reading); } const auto reject_overlap = [&](const std::filesystem::path &resources, const std::filesystem::path &user, const std::filesystem::path &cache) { bool rejected = false; try { (void)prepare_runtime_paths(resources, user, cache, "overlap"); } catch (const std::invalid_argument &) { rejected = true; } require(rejected, "Overlapping runtime roots were accepted"); require(!std::filesystem::exists(user / "dictionaries/overlap"), "Rejected runtime roots still published a generation"); }; reject_overlap(resource_a, root / "same-user-cache", root / "same-user-cache"); reject_overlap(resource_a, root / "cache-parent/user", root / "cache-parent"); reject_overlap(resource_a, root / "user-parent", root / "user-parent/cache"); reject_overlap(resource_a, resource_a / "user", root / "separate-cache"); reject_overlap(resource_a, root / "separate-user", resource_a / "cache"); reject_overlap(resource_a, root, root / "separate-cache"); require(!std::filesystem::exists(root / "same-user-cache") && !std::filesystem::exists(root / "cache-parent") && !std::filesystem::exists(resource_a / "user") && !std::filesystem::exists(resource_a / "cache"), "Path validation created directories before rejecting overlap"); #ifndef _WIN32 const auto resource_alias = root / "resource-alias"; std::filesystem::create_directory_symlink(resource_a, resource_alias); reject_overlap(resource_a, root / "alias-user", resource_alias / "cache"); #endif const auto original = bytes(resource_a / assets::main_dictionary); const auto paths_a = prepare_runtime_paths(resource_a, root / "user-a", root / "cache-a", "v1"); const auto paths_b = prepare_runtime_paths(resource_b, root / "user-b", root / "cache-b", "v1"); { SessionOptions options; options.paths = paths_a; options.learning = false; Session session(options); Session independent(options); type(session, "ni"); session.command(Command::MoveLeft); const auto before = session.snapshot(); session.set_chinese_punctuation_enabled(false); session.set_chinese_punctuation_enabled(false); const auto unchanged = session.snapshot(); require(unchanged.editing_text == before.editing_text && unchanged.preedit == before.preedit && unchanged.caret_position == before.caret_position && unchanged.candidates.size() == before.candidates.size(), "Live punctuation mode changed composition or caret"); const auto ignored = session.punctuation(','); require(!ignored.handled && !ignored.commit && session.snapshot().editing_text == before.editing_text, "ASCII punctuation mode consumed composition"); require(independent.punctuation(',').commit == std::optional(","), "Live punctuation mode leaked to another session"); session.command(Command::Cancel); session.set_chinese_punctuation_enabled(true); require(session.punctuation('"').commit == std::optional("“"), "Chinese punctuation did not reopen"); session.set_chinese_punctuation_enabled(false); require(!session.punctuation('"').handled, "Disabled quote was consumed"); session.set_chinese_punctuation_enabled(true); require(session.punctuation('"').commit == std::optional("”"), "Punctuation mode reset quote pairing"); } { SessionOptions options; options.paths = paths_a; options.scheme = SchemeType::Shuangpin; options.shuangpin_profile = GetMicrosoftShuangpinProfile(); options.learning = false; Session session(options); require(!session.character(';').handled, "Microsoft semicolon started a syllable"); session.character('n'); const auto ing = session.character(';'); require(ing.handled && !ing.commit && session.snapshot().preedit == "n;", "Public Session rejected Microsoft ing final"); const auto query = session.online_query(); require(query && query->query_text == "ning", "Microsoft ing lost its canonical query"); require(!session.character(';').handled && session.snapshot().preedit == "n;", "Microsoft semicolon accepted outside the final position"); session.command(Command::Backspace); require(session.snapshot().preedit == "n", "Microsoft final could not be erased"); session.command(Command::Cancel); type(session, "ni'n"); require(session.character(';').handled && session.snapshot().preedit == "ni'n;", "Microsoft final after a manual separator was rejected"); session.command(Command::MoveLeft); session.command(Command::DeleteForward); // Leave a suffix so the replacement exercises the middle-edit path. session.command(Command::MoveEnd); type(session, "ni"); session.command(Command::MoveLeft); session.command(Command::MoveLeft); require(session.character(';').handled && session.snapshot().editing_text == "ni'n;ni", "Microsoft ing could not be inserted before a suffix"); session.command(Command::Cancel); type(session, "ni'"); require(!session.character(';').handled && session.snapshot().preedit == "ni'", "Microsoft separator was counted as a syllable initial"); options.shuangpin_profile = GetXiaoheShuangpinProfile(); Session xiaohe(options); xiaohe.character('n'); require(!xiaohe.character(';').handled && xiaohe.snapshot().preedit == "n", "Microsoft semicolon leaked to Xiaohe"); } { SessionOptions options; options.paths = paths_a; options.learning = false; Session session(options), other(options); require(!session.command(Command::MoveLeft).handled, "Empty caret movement consumed host input"); type(session, "ni"); type(other, "ni"); const auto query = session.online_query(); require(query.has_value(), "Missing pre-edit online request"); session.command(Command::MoveHome); require(session.snapshot().caret_position == 0 && other.snapshot().caret_position == 2, "Caret movement leaked between sessions"); require(session.online_query()->generation == query->generation, "Caret-only movement invalidated unchanged candidates"); session.command(Command::Backspace); require(session.snapshot().editing_text == "ni", "Home backspace removed input"); session.command(Command::DeleteForward); require(session.snapshot().editing_text == "i", "Forward delete removed the wrong character"); session.character('n'); require(session.snapshot().editing_text == "ni" && session.snapshot().caret_position == 1 && session.snapshot().candidates.front().word == "你", "Middle insertion did not refresh candidates"); require(!session.apply_online_candidate(*query, "旧响应", CandidateSource::CloudSuggestion), "Editing back to the same text accepted an old online response"); session.character('\''); session.character('\''); require(session.snapshot().editing_text == "n'i", "Duplicate separator was inserted at caret"); session.command(Command::Backspace); session.command(Command::MoveEnd); session.command(Command::DeleteForward); require(session.snapshot().editing_text == "ni" && session.snapshot().caret_position == 2, "End forward delete changed input"); session.command(Command::MoveHome); require(session.select(0).commit == "你" && session.snapshot().caret_position == 0, "Selection did not clear caret with composition"); type(session, "ni"); require(session.snapshot().caret_position == 2, "New composition inherited the old caret"); } for (const char marker : {'Y', 'J', 'E', 'M', 'K', 'U', 'R'}) { SessionOptions options; options.paths = paths_a; options.learning = false; Session session(options); session.character(marker, true); const std::string payload = marker == 'U' ? "41" : "ka"; type(session, payload); session.command(Command::MoveHome); require(session.snapshot().caret_position == 1, "Local-mode caret crossed its marker"); session.command(Command::Backspace); session.command(Command::DeleteForward); session.character(payload.front()); require(session.snapshot().editing_text == std::string(1, marker) + payload && session.snapshot().caret_position == 2, "Local-mode middle edit lost its source text"); session.command(Command::Cancel); require(session.snapshot().editing_text.empty() && session.snapshot().caret_position == 0, "Cancel retained local-mode editing state"); } for (const auto scheme : {SchemeType::Shuangpin, SchemeType::Wubi, SchemeType::JapaneseRomaji}) { SessionOptions options; options.paths = paths_a; options.scheme = scheme; options.learning = false; Session session(options); type(session, "ka"); session.command(Command::MoveLeft); session.command(Command::Backspace); session.character('k'); require(session.snapshot().editing_text == "ka" && session.snapshot().caret_position == 1, "Scheme edit used rendered text instead of raw source"); session.switch_scheme(SchemeType::Quanpin); require(session.snapshot().caret_position == 0, "Scheme change retained the old caret"); } { SessionOptions options; options.paths = paths_a; Session session(options); session.set_dedicated_english(true); type(session, "ello"); session.command(Command::MoveHome); session.character('H'); require(session.snapshot().editing_text == "Hello" && session.snapshot().caret_position == 1 && session.snapshot().candidates.front().word == "hello", "English middle edit lost case or candidates"); } { execute(paths_a.dictionary(assets::main_dictionary), "CREATE TABLE tbl_1_h(key TEXT,jp TEXT,value TEXT,weight INTEGER);" "INSERT INTO tbl_1_h VALUES('hao','h','好',10000);"); SessionOptions options; options.paths = paths_a; options.learning = false; Session session(options); type(session, "nihao"); const auto view = session.snapshot(); const auto prefix = std::find_if(view.candidates.begin(), view.candidates.end(), [](const auto &word) { return word.word == "你"; }); require(prefix != view.candidates.end(), "Missing partial-selection fixture"); session.command(Command::MoveHome); const auto selected = session.select(static_cast(prefix - view.candidates.begin())); require(selected.commit == "你" && session.snapshot().editing_text == "hao" && session.snapshot().caret_position == 3, "Partial selection retained the old caret"); session.command(Command::MoveHome); session.command(Command::DeleteForward); session.character('h'); require(session.finish().commit == "好", "Editing the remainder lost the selected prefix boundary"); } require(paths_a.resources != paths_a.dictionaries && paths_a.cache != paths_a.dictionaries, "Mutable dictionaries must live separately from resources and cache"); { SessionOptions options_a; options_a.paths = paths_a; options_a.learning = false; SessionOptions options_b; options_b.paths = paths_b; options_b.learning = false; Session a(options_a), b(options_b); type(a, "ni"); type(b, "ni"); require(a.snapshot().candidates.front().word == "你", "Session A used another resource directory"); require(b.snapshot().candidates.front().word == "妮", "Session B used another resource directory"); const auto query_a = a.online_query(); require(query_a.has_value(), "No online request for complete pinyin"); require(!b.apply_online_candidate(*query_a, "跨会话建议", CandidateSource::CloudSuggestion), "Another session's online response was accepted"); require(a.apply_online_candidate(*query_a, "本会话建议", CandidateSource::CloudSuggestion), "Own online response was rejected"); a.command(Command::Cancel); b.command(Command::Cancel); require(a.set_helpcode_schema("lantian") && b.set_helpcode_schema("xiaohe"), "Cannot select helpcodes"); type(a, "niC"); type(b, "niC"); require(a.snapshot().candidates.front().word == "拟", "Session A helpcode changed with B"); require(b.snapshot().candidates.front().word == "妮", "Session B helpcode was not isolated"); require(a.punctuation('"').commit == "拟“", "Punctuation did not flush composition"); b.command(Command::Cancel); require(b.punctuation('"').commit == "“", "Quotation state leaked across sessions"); const auto run_session = [](Session &session, const std::string &expected) { for (int i = 0; i < 20; ++i) { session.command(Command::Cancel); type(session, "ni"); const auto snapshot = session.snapshot(); require(!snapshot.candidates.empty() && snapshot.candidates.front().word == expected, "Concurrent session read another user's dictionary"); } }; auto first_session = std::async(std::launch::async, run_session, std::ref(a), "你"); auto second_session = std::async(std::launch::async, run_session, std::ref(b), "妮"); first_session.get(); second_session.get(); a.command(Command::Cancel); a.character('K', true); a.character('x'); require(a.snapshot().candidates.front().word == "你短语", "Local mode ignored runtime paths"); } { SessionOptions options; options.paths = paths_a; options.learning = false; Session session(options); type(session, "ni'ni"); const auto view = session.snapshot(); const auto selected = std::find_if(view.candidates.begin(), view.candidates.end(), [](const auto &item) { return item.word == "拟"; }); require(selected != view.candidates.end(), "No alternate first-segment candidate"); require(session.finish(static_cast(selected - view.candidates.begin())).commit == "拟你", "Finishing ignored the highlight or dropped the remaining segment"); require(session.snapshot().preedit.empty(), "Finishing left an active composition"); require(!session.finish().handled, "Finishing an empty session consumed input"); type(session, "ni"); require(session.finish(9999).commit == "ni", "Invalid highlight did not preserve raw input"); session.set_helpcode_enabled(false); type(session, "ni"); require(!session.character('C').handled, "Disabled helpcode still consumed uppercase input"); session.set_helpcode_enabled(true); require(session.character('C').handled && session.snapshot().candidates.front().word == "拟", "Re-enabling helpcode did not update this composition"); session.command(Command::Cancel); session.set_dedicated_english(true); require(session.snapshot().dedicated_english, "Empty dedicated English mode was lost in the snapshot"); type(session, "hel"); require(session.snapshot().dedicated_english && session.snapshot().preedit == "hel", "Dedicated English state was not represented in the snapshot"); session.set_dedicated_english(false); require(!session.snapshot().dedicated_english && session.snapshot().preedit.empty(), "Leaving dedicated English left stale snapshot state"); } { SessionOptions options_a; options_a.paths = paths_a; options_a.scheme = SchemeType::JapaneseRomaji; SessionOptions options_b; options_b.paths = paths_b; options_b.scheme = SchemeType::JapaneseRomaji; Session a(options_a), b(options_b); type(a, "kana"); type(b, "kana"); const auto has = [](const Session &session, const std::string &word) { const auto snapshot = session.snapshot(); return std::any_of(snapshot.candidates.begin(), snapshot.candidates.end(), [&](const auto &candidate) { return candidate.word == word; }); }; require(has(a, "甲") && !has(a, "乙"), "Japanese session A used another model"); require(has(b, "乙") && !has(b, "甲"), "Japanese session B reused the first model"); } // Explicit pinning must use the selected canonical key, preserve input, and survive replay. const auto pin_resources = root / "pin-resources"; make_resources(pin_resources, "你"); execute(pin_resources / assets::main_dictionary, "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 wubi86(key TEXT,value TEXT,weight INTEGER);" "INSERT INTO wubi86 VALUES('wq','你好',10000),('wq','拟好',9000);"); execute(pin_resources / assets::english_dictionary, "INSERT INTO english_words(word,display,weight) VALUES('help','help',5);"); const auto pin_main_bytes = bytes(pin_resources / assets::main_dictionary); const auto pin_english_bytes = bytes(pin_resources / assets::english_dictionary); for (const auto scheme : {SchemeType::Quanpin, SchemeType::Shuangpin, SchemeType::Wubi}) { const auto suffix = std::to_string(static_cast(scheme)); const auto user = root / ("position-user-" + suffix); const auto cache = root / ("position-cache-" + suffix); SessionOptions options; options.paths = prepare_runtime_paths(pin_resources, user, cache, "v1"); options.scheme = scheme; options.learning = false; const auto input = scheme == SchemeType::Quanpin ? "nihao" : (scheme == SchemeType::Shuangpin ? "nihc" : "wq"); { Session session(options); type(session, input); const auto before = session.snapshot(); const auto found = std::find_if(before.candidates.begin(), before.candidates.end(), [](const auto &word) { return word.word == "拟好"; }); require(found != before.candidates.end(), "Missing fixed-position fixture"); const auto index = static_cast(found - before.candidates.begin()); require(!session.fix_position(index, 0).handled && !session.fix_position(index, 6).handled, "Invalid fixed slot accepted"); const auto fixed = session.fix_position(index, 1); require(fixed.handled && !fixed.commit && !fixed.diagnostic && session.snapshot().preedit == before.preedit && session.snapshot().candidates.front().word == "拟好" && session.snapshot().candidates.front().fixed_position == 1, "Fixed slot did not refresh snapshot"); execute(options.paths.user(assets::user_journal), "CREATE TRIGGER reject_fixed BEFORE INSERT ON fixed_candidate_positions " "BEGIN SELECT RAISE(ABORT,'fixture rejection'); END;"); const auto rejected = session.fix_position(0, 2); require(rejected.handled && rejected.diagnostic && !rejected.commit && session.snapshot().candidates.front().fixed_position == 1, "Failed fixed-slot write changed the snapshot or hid its diagnostic"); execute(options.paths.user(assets::user_journal), "DROP TRIGGER reject_fixed"); auto independent_options = options; independent_options.paths = prepare_runtime_paths(pin_resources, root / ("independent-position-user-" + suffix), root / ("independent-position-cache-" + suffix), "v1"); Session independent(independent_options); type(independent, input); require(independent.snapshot().candidates.front().word == "你好", "Fixed position leaked into another user layout"); } options.paths = prepare_runtime_paths(pin_resources, user, cache, "v2"); Session replayed(options); type(replayed, input); require(replayed.snapshot().candidates.front().word == "拟好", "Fixed slot lost after generation change"); const auto cleared = replayed.clear_position(0); require(cleared.handled && !cleared.diagnostic && !cleared.commit && replayed.snapshot().candidates.front().word == "你好", "Clearing slot did not restore dictionary order"); } for (const int mode : {0, 1, 2}) { SessionOptions options; options.paths = prepare_runtime_paths(pin_resources, root / ("fixed-english-" + std::to_string(mode)), root / ("fixed-english-cache-" + std::to_string(mode)), "v1"); options.learning = false; options.english.mixed_candidates = mode == 2; Session session(options); if (mode == 1) session.character('Y', true); else if (mode == 0) session.set_dedicated_english(true); type(session, "he"); const auto before = session.snapshot(); const auto found = std::find_if(before.candidates.begin(), before.candidates.end(), [](const auto &word) { return word.word == "help"; }); require(found != before.candidates.end(), "Missing English fixed-slot fixture"); const auto fixed = session.fix_position(static_cast(found - before.candidates.begin()), 1); require(fixed.handled && !fixed.diagnostic && session.snapshot().candidates.front().word == "help", "English fixed slot was not applied"); require(session.clear_position(0).handled, "English fixed slot could not be cleared"); } const auto pin_word = [&](Session &session, const std::string &word) { const auto before = session.snapshot(); const auto selected = std::find_if(before.candidates.begin(), before.candidates.end(), [&](const auto &item) { return item.word == word; }); require(selected != before.candidates.end(), "No candidate for explicit pinning"); const auto result = session.pin(static_cast(selected - before.candidates.begin())); require(result.handled && !result.commit && !result.diagnostic, "Explicit pin failed or committed text"); require(session.snapshot().preedit == before.preedit, "Pin changed the composition"); const auto after = session.snapshot(); const auto first = std::find_if(after.candidates.begin(), after.candidates.end(), [&](const auto &item) { return item.source == selected->source; }); require(first != after.candidates.end() && first->word == word, "Pin did not refresh dictionary candidate order"); require(!session.pin(before.candidates.size() + 100).handled, "Invalid pin index was accepted"); }; for (const auto scheme : {SchemeType::Quanpin, SchemeType::Shuangpin, SchemeType::Wubi}) { const auto suffix = std::to_string(static_cast(scheme)); const auto user = root / ("pin-user-" + suffix); const auto cache = root / ("pin-cache-" + suffix); SessionOptions options; options.paths = prepare_runtime_paths(pin_resources, user, cache, "v1"); options.scheme = scheme; options.learning = false; const std::string input = scheme == SchemeType::Quanpin ? "nihao" : (scheme == SchemeType::Shuangpin ? "nihc" : "wq"); { Session session(options); require(!session.pin(0).handled, "Empty session accepted pin"); type(session, input); pin_word(session, "拟好"); require(session.finish().commit == "拟好", "Pin interfered with later selection"); } options.paths = prepare_runtime_paths(pin_resources, user, cache, "v2"); Session replayed(options); type(replayed, input); require(replayed.snapshot().candidates.front().word == "拟好", "Pin did not survive generation replay"); } for (const bool temporary : {false, true}) { SessionOptions options; const std::string suffix = temporary ? "temporary" : "dedicated"; const auto user = root / ("pin-english-user-" + suffix); const auto cache = root / ("pin-english-cache-" + suffix); options.paths = prepare_runtime_paths(pin_resources, user, cache, "v1"); options.learning = false; { Session session(options); if (temporary) session.character('Y', true); else session.set_dedicated_english(true); type(session, "he"); pin_word(session, "help"); } options.paths = prepare_runtime_paths(pin_resources, user, cache, "v2"); Session replayed(options); replayed.set_dedicated_english(true); type(replayed, "he"); require(replayed.snapshot().candidates.front().word == "help", "English pin did not survive replay"); replayed.command(Command::Cancel); type(replayed, "zzzz"); require(!replayed.pin(0).handled, "Generated candidate accepted dictionary pinning"); } { SessionOptions options; options.paths = prepare_runtime_paths(pin_resources, root / "pin-mixed-user", root / "pin-mixed-cache", "v1"); options.learning = false; options.english.mixed_candidates = true; Session mixed(options); type(mixed, "he"); pin_word(mixed, "help"); } { SessionOptions options; options.paths = prepare_runtime_paths(pin_resources, root / "pin-failed-user", root / "pin-failed-cache", "v1"); options.learning = false; // A directory in place of the journal is a portable, deterministic write failure. std::filesystem::remove(options.paths.user(assets::user_journal)); std::filesystem::create_directory(options.paths.user(assets::user_journal)); Session session(options); type(session, "nihao"); const auto before = session.snapshot(); const auto selected = std::find_if(before.candidates.begin(), before.candidates.end(), [](const auto &item) { return item.word == "拟好"; }); require(selected != before.candidates.end(), "No candidate for failed pin test"); const auto result = session.pin(static_cast(selected - before.candidates.begin())); require(result.handled && !result.commit && result.diagnostic.has_value(), "Failed pin was reported as success or committed text"); require(session.snapshot().preedit == before.preedit && session.snapshot().candidates.front().word == before.candidates.front().word, "Failed pin changed input or candidate order"); } require(bytes(pin_resources / assets::main_dictionary) == pin_main_bytes && bytes(pin_resources / assets::english_dictionary) == pin_english_bytes, "Explicit pinning modified immutable resources"); // Query/learning changes affect only the user working copy and journal; replay retains them. { QuanpinDictionary dictionary({}, paths_a); require(dictionary.create_word_from_canonical_pinyin("ni", "伱") == 0, "User insertion failed"); } require(bytes(resource_a / assets::main_dictionary) == original, "Learning modified immutable source"); std::ofstream(paths_a.cache / "disposable") << "cache fixture"; std::filesystem::remove_all(paths_a.cache); require(std::filesystem::is_regular_file(paths_a.user(assets::user_journal)), "Clearing cache removed the user journal"); { QuanpinDictionary dictionary({}, paths_a); require(dictionary.find_candidate("ni", "伱").has_value(), "Clearing cache removed learned vocabulary"); } const auto paths_v2 = prepare_runtime_paths(resource_a, paths_a.user_data, paths_a.cache, "v2"); { QuanpinDictionary dictionary({}, paths_v2); require(dictionary.find_candidate("ni", "伱").has_value(), "Upgrade lost user phrase replay"); EnglishDictionary english(path_to_utf8(paths_v2.dictionary(assets::english_dictionary)), false, path_to_utf8(paths_v2.resource(assets::translations))); require(english.query_chinese_gloss("hello") == "你翻译", "Translation sidecar did not use resource path"); } { QuanpinDictionary dictionary({}, paths_v2); require(dictionary.create_word_from_canonical_pinyin("ni", "倪") == 0, "Second-generation insertion failed"); } const auto restored_v1 = prepare_runtime_paths(resource_a, paths_a.user_data, paths_a.cache, "v1"); { QuanpinDictionary dictionary({}, restored_v1); require(dictionary.find_candidate("ni", "倪").has_value(), "Returning to old generation lost newer learning"); } const auto before_failure = bytes(paths_v2.dictionary(assets::main_dictionary)); require(user_dictionary::record_upsert(path_to_utf8(paths_a.user(assets::user_journal)), user_dictionary::DictionaryKind::Pinyin, "@", "无效词", 10), "Cannot write failure fixture"); bool rejected = false; try { (void)prepare_runtime_paths(resource_a, paths_a.user_data, paths_a.cache, "v3"); } catch (const std::runtime_error &) { rejected = true; } require(rejected, "Invalid replay did not reject generation"); require(bytes(paths_v2.dictionary(assets::main_dictionary)) == before_failure, "Failed replay replaced working data"); require(!std::filesystem::exists(paths_a.user_data / "dictionaries/v3"), "Failed generation was published"); require(!std::filesystem::exists(paths_a.user_data / "dictionaries/v3.incoming"), "Failed staging was retained"); // Use the real pinned decoder model. Interleave independent users, invalid model loading, // destruction, and concurrent calls; candidate contents must equal isolated decoding. const auto model = resource_a / assets::pinyin_model; std::filesystem::copy_file(path_from_utf8(METASEQUOIA_TEST_PINYIN_MODEL), model); { PinyinDecoder decoder_a(model, resource_a / "decoder-a.dat"), decoder_b(model, resource_b / "decoder-b.dat"); const auto expected_a = decoder_a.sentence("nihao"); const auto expected_b = decoder_b.sentence("zhongguo"); require(!expected_a.empty() && !expected_b.empty(), "Real decoder model did not load"); { PinyinDecoder missing(root / "missing.dat", root / "missing-user.dat"); require(missing.sentence("nihao").empty(), "Missing model used another session's decoder"); } require(decoder_a.sentence("nihao") == expected_a, "Failed model load damaged existing session"); const auto run = [](const PinyinDecoder &decoder, const std::string &input, const std::string &expected) { for (int i = 0; i < 12; ++i) require(decoder.sentence(input) == expected, "Concurrent decoder result leaked across sessions"); }; auto first = std::async(std::launch::async, run, std::cref(decoder_a), "nihao", expected_a); auto second = std::async(std::launch::async, run, std::cref(decoder_b), "zhongguo", expected_b); first.get(); second.get(); } } int main() { test_runtime_isolation(); return 0; }