Skip to main content

msime_engine/user_dictionary/
state.rs

1//! Dictionary-state export and import (user-dictionary.md §14, api-contract §5.13): one consistent read of the journal's entries, fixed positions and selection counts, its SHA-256 revision, and staging a complete replacement generation from a record stream.
2
3use std::collections::HashMap;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, OnceLock, PoisonError};
8use std::time::{Duration, SystemTime};
9
10use rusqlite::{params, OpenFlags, TransactionBehavior};
11use sha2::{Digest, Sha256};
12
13use crate::assets;
14use crate::diagnostics;
15use crate::error::{EngineError, Result};
16use crate::paths::RuntimePaths;
17use crate::types::{PersonalDictionaryKind, SchemeSet};
18use crate::user_dictionary::generation::{
19    prepare_runtime_paths_for, roots_overlap, weakly_canonical,
20};
21use crate::user_dictionary::journal::{ensure_schema, open_database};
22
23pub const DEFAULT_MAXIMUM_RECORDS: usize = 500_000;
24
25const MAX_KEY_BYTES: usize = 512;
26const MAX_VALUE_BYTES: usize = 4_096;
27const MAX_STAGED_WEIGHT: i64 = 100_000_000;
28const MAX_STAGED_SELECTION_COUNT: i64 = 10;
29
30/// The domain separator every revision starts with; Apple's `DictionaryStateRevision` uses the same bytes.
31const REVISION_DOMAIN: &str = "msime-local-dictionary-state-v1";
32
33/// The host's record shape (engine-bridge `DictionaryStateRecord`); positions and counts are range-checked on import.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum DictionaryStateRecord {
36    Entry {
37        kind: PersonalDictionaryKind,
38        key: String,
39        value: String,
40        weight: i64,
41        display: String,
42        deleted: bool,
43        user_inserted: bool,
44    },
45    Position {
46        context: String,
47        key: String,
48        value: String,
49        position: i64,
50    },
51    Selection {
52        context: String,
53        key: String,
54        value: String,
55        count: i64,
56    },
57}
58
59/// Every failure reading or staging a state has the one message the reference's `require` threw (DS:18-22): hosts show it as is, and the cause is not user-actionable.
60fn invalid_state() -> EngineError {
61    EngineError::failed(diagnostics::INVALID_DICTIONARY_STATE)
62}
63
64fn require(condition: bool) -> Result<()> {
65    if condition {
66        Ok(())
67    } else {
68        Err(invalid_state())
69    }
70}
71
72/// Operations by (dictionary, key, value), then positions, then selections, in one read transaction with `query_only` (DS:93-138). `emit` returning false stops. A missing journal emits nothing and is not created; a corrupt one is an error and is left untouched.
73///
74/// Stopping is reported as `INVALID_DICTIONARY_STATE`, as the reference threw for a cancelled export. Text is read strictly: a row that is not UTF-8 is an invalid state rather than a lossy copy, because an import would otherwise store different bytes than were exported.
75pub fn stream_dictionary_state(
76    paths: &RuntimePaths,
77    emit: &mut dyn FnMut(&DictionaryStateRecord) -> bool,
78) -> Result<()> {
79    paths.validate()?;
80    let file = paths.user(assets::USER_JOURNAL);
81    if !file.exists() {
82        return Ok(());
83    }
84    read_state(&file, emit).map_err(|error| match error {
85        EngineError::Sqlite(_) => invalid_state(),
86        other => other,
87    })
88}
89
90fn read_state(file: &Path, emit: &mut dyn FnMut(&DictionaryStateRecord) -> bool) -> Result<()> {
91    // SQLite may need to create WAL coordination files even for a reader, so the connection may write files, but `query_only` forbids changing the journal itself (DS:98-102).
92    let mut connection = open_database(file, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
93    connection.execute_batch("PRAGMA query_only=ON")?;
94    let transaction = connection.transaction_with_behavior(TransactionBehavior::Deferred)?;
95    let mut records = 0usize;
96    {
97        let mut rows = transaction.prepare(
98            "SELECT dictionary,key,value,weight,display,operation,user_inserted,
99                    length(CAST(dictionary AS BLOB)),length(CAST(key AS BLOB)),
100                    length(CAST(value AS BLOB)),length(CAST(display AS BLOB)),
101                    length(CAST(operation AS BLOB))
102             FROM user_dictionary_operations ORDER BY dictionary,key,value",
103        )?;
104        let mut cursor = rows.query([])?;
105        while let Some(row) = cursor.next()? {
106            require(records < DEFAULT_MAXIMUM_RECORDS)?;
107            require(
108                row.get::<_, Option<i64>>(7)?
109                    .is_some_and(|length| (0..=32).contains(&length))
110                    && row
111                        .get::<_, Option<i64>>(8)?
112                        .is_some_and(|length| (0..=MAX_KEY_BYTES as i64).contains(&length))
113                    && row
114                        .get::<_, Option<i64>>(9)?
115                        .is_some_and(|length| (0..=MAX_VALUE_BYTES as i64).contains(&length))
116                    && row
117                        .get::<_, Option<i64>>(10)?
118                        .is_some_and(|length| (0..=MAX_VALUE_BYTES as i64).contains(&length))
119                    && row
120                        .get::<_, Option<i64>>(11)?
121                        .is_some_and(|length| (0..=16).contains(&length)),
122            )?;
123            let kind = PersonalDictionaryKind::from_journal_name(&row.get::<_, String>(0)?)
124                .ok_or_else(invalid_state)?;
125            // `sqlite3_column_int64` reads NULL as 0; legacy journals built without NOT NULL can hold one.
126            let record = DictionaryStateRecord::Entry {
127                kind,
128                key: row.get(1)?,
129                value: row.get(2)?,
130                weight: row.get::<_, Option<i64>>(3)?.unwrap_or(0),
131                display: row.get(4)?,
132                deleted: row.get::<_, String>(5)? == "delete",
133                user_inserted: row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0,
134            };
135            require(emit(&record))?;
136            records += 1;
137        }
138    }
139    for fixed in [true, false] {
140        let mut rows = transaction.prepare(if fixed {
141            "SELECT context_key,entry_key,value,position,
142                    length(CAST(context_key AS BLOB)),length(CAST(entry_key AS BLOB)),
143                    length(CAST(value AS BLOB))
144             FROM fixed_candidate_positions ORDER BY context_key,entry_key,value"
145        } else {
146            "SELECT context_key,entry_key,value,selection_count,
147                    length(CAST(context_key AS BLOB)),length(CAST(entry_key AS BLOB)),
148                    length(CAST(value AS BLOB))
149             FROM candidate_selection_state ORDER BY context_key,entry_key,value"
150        })?;
151        let mut cursor = rows.query([])?;
152        while let Some(row) = cursor.next()? {
153            require(records < DEFAULT_MAXIMUM_RECORDS)?;
154            require(
155                row.get::<_, Option<i64>>(4)?
156                    .is_some_and(|length| (0..=MAX_KEY_BYTES as i64).contains(&length))
157                    && row
158                        .get::<_, Option<i64>>(5)?
159                        .is_some_and(|length| (0..=MAX_KEY_BYTES as i64).contains(&length))
160                    && row
161                        .get::<_, Option<i64>>(6)?
162                        .is_some_and(|length| (0..=MAX_VALUE_BYTES as i64).contains(&length)),
163            )?;
164            let context: String = row.get(0)?;
165            let key: String = row.get(1)?;
166            let value: String = row.get(2)?;
167            let number = row.get::<_, Option<i64>>(3)?.unwrap_or(0);
168            let record = if fixed {
169                DictionaryStateRecord::Position {
170                    context,
171                    key,
172                    value,
173                    position: number,
174                }
175            } else {
176                DictionaryStateRecord::Selection {
177                    context,
178                    key,
179                    value,
180                    count: number,
181                }
182            };
183            require(emit(&record))?;
184            records += 1;
185        }
186    }
187    transaction.commit()?;
188    Ok(())
189}
190
191/// SHA-256 hex over `text("msime-local-dictionary-state-v1")` and every streamed record, where `text(s)` is `integer(len)` then the bytes and `integer` is a big-endian u64 (dictionary_revision.rs). Apple computes the same digest; keep the framing byte for byte.
192///
193/// 每次都完整读一遍日志。算完顺带把结果记进 [`cached_dictionary_state_revision`] 的缓存,所以准备、激活快照这些必须拿精确值的地方算出来的新结果,之后的预览也直接用上。
194pub fn dictionary_state_revision(paths: &RuntimePaths) -> Result<String> {
195    state_revision(paths, false, SystemTime::now)
196}
197
198/// 与 [`dictionary_state_revision`] 结果相同,但日志主文件和 `-wal`、`-journal` 旁路文件自上次完整计算以来都没变时,直接返回上次的结果,不打开 SQLite。
199///
200/// 宿主在每次失焦、每隔几秒的空闲轮询里都要拿这个修订号发布本机词库版本,日志动辄几十万行,每次都排序全表扫描再做 SHA-256,低端机上就是持续的耗电和发热,而绝大多数时候日志根本没动。判断“没动”只看 stat:长度、修改时间,unix 上再加设备号、inode 和状态改变时间。SQLite 的每次提交都会改主文件(回滚日志模式)或追加、重写 `-wal`(WAL 模式),所以内容变了这几项必然有一项变;检查点、换文件这类内容不变的改动只会多算一次,不会少算。长度为零的旁路文件里没有任何页,与不存在等价,按不存在处理,免得会话开关连接时建了又删的空 `-wal` 让缓存每次都失效。
201///
202/// 修改时间的精度有限(Linux 按时钟节拍取粗粒度时间,部分文件系统只到秒),同一个时间粒度里先 stat、后又写入的话 stat 可能完全一样。所以只有文件的最后改动离现在已经超过 [`REVISION_SETTLE`] 时才记缓存,跟 git 处理 racy-clean 索引是同一个思路:之后的任何写入都会带上比记下的更晚的时间戳。刚写过的日志每次都完整计算。stat 失败也一律完整计算、不记缓存。
203pub fn cached_dictionary_state_revision(paths: &RuntimePaths) -> Result<String> {
204    state_revision(paths, true, SystemTime::now)
205}
206
207/// `now` 只在要记缓存时才取:wasm32-unknown-unknown 上取系统时间会 panic,而那里 stat 本来就失败,走不到这一步。
208fn state_revision(
209    paths: &RuntimePaths,
210    use_cache: bool,
211    now: impl FnOnce() -> SystemTime,
212) -> Result<String> {
213    paths.validate()?;
214    let journal = paths.user(assets::USER_JOURNAL);
215    // 开始读日志之前取:读的过程中又有写入的话,之后再 stat 必然与它不同,不会拿这次的结果去回答。
216    let stamp = JournalStamp::read(&journal);
217    if use_cache {
218        if let Some(stamp) = &stamp {
219            let cache = revision_cache()
220                .lock()
221                .unwrap_or_else(PoisonError::into_inner);
222            if let Some((cached, revision)) = cache.get(&journal) {
223                if cached == stamp {
224                    return Ok(revision.clone());
225                }
226            }
227        }
228    }
229    let mut revision = Revision::new();
230    stream_dictionary_state(paths, &mut |record| {
231        revision.record(record);
232        true
233    })?;
234    let revision = revision.finish();
235    if let Some(stamp) = stamp.filter(|stamp| stamp.settled(now())) {
236        let mut cache = revision_cache()
237            .lock()
238            .unwrap_or_else(PoisonError::into_inner);
239        if cache.len() >= REVISION_CACHE_LIMIT && !cache.contains_key(&journal) {
240            cache.clear();
241        }
242        cache.insert(journal, (stamp, revision.clone()));
243    }
244    Ok(revision)
245}
246
247/// 日志文件最后一次改动要早于现在多久,这次算出的修订号才记进缓存,见 [`cached_dictionary_state_revision`]。两秒盖过 FAT 这类最粗的两秒时间精度。
248pub(crate) const REVISION_SETTLE: Duration = Duration::from_secs(2);
249
250/// 宿主进程里通常只有一份日志;准备快照时会多出暂存代次的日志,测试里是各自的临时目录。满了就整个清掉重来。
251const REVISION_CACHE_LIMIT: usize = 16;
252
253/// 按日志路径记下的 stat 和修订号。
254fn revision_cache() -> &'static Mutex<HashMap<PathBuf, (JournalStamp, String)>> {
255    static CACHE: OnceLock<Mutex<HashMap<PathBuf, (JournalStamp, String)>>> = OnceLock::new();
256    CACHE.get_or_init(Default::default)
257}
258
259/// 日志主文件、`-wal`、`-journal` 各自的 stat,`None` 是不存在(旁路文件长度为零也算)。
260#[derive(Debug, Clone, PartialEq, Eq)]
261struct JournalStamp([Option<FileStamp>; 3]);
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264struct FileStamp {
265    length: u64,
266    modified: SystemTime,
267    #[cfg(unix)]
268    device: u64,
269    #[cfg(unix)]
270    inode: u64,
271    #[cfg(unix)]
272    changed: (i64, i64),
273}
274
275impl JournalStamp {
276    /// 任何一个文件 stat 失败(不存在除外)时是 `None`,这次不查也不记缓存。
277    fn read(journal: &Path) -> Option<Self> {
278        let side = |suffix: &str| {
279            let mut name = journal.as_os_str().to_owned();
280            name.push(suffix);
281            PathBuf::from(name)
282        };
283        Some(Self([
284            FileStamp::read(journal, false)?,
285            FileStamp::read(&side("-wal"), true)?,
286            FileStamp::read(&side("-journal"), true)?,
287        ]))
288    }
289
290    fn settled(&self, now: SystemTime) -> bool {
291        let Some(horizon) = now.checked_sub(REVISION_SETTLE) else {
292            return false;
293        };
294        self.0
295            .iter()
296            .flatten()
297            .all(|file| file.last_change().is_some_and(|time| time < horizon))
298    }
299}
300
301impl FileStamp {
302    fn read(path: &Path, empty_is_absent: bool) -> Option<Option<Self>> {
303        let metadata = match fs::symlink_metadata(path) {
304            Ok(metadata) => metadata,
305            Err(error) if error.kind() == ErrorKind::NotFound => return Some(None),
306            Err(_) => return None,
307        };
308        if empty_is_absent && metadata.len() == 0 {
309            return Some(None);
310        }
311        #[cfg(unix)]
312        use std::os::unix::fs::MetadataExt;
313        Some(Some(Self {
314            length: metadata.len(),
315            modified: metadata.modified().ok()?,
316            #[cfg(unix)]
317            device: metadata.dev(),
318            #[cfg(unix)]
319            inode: metadata.ino(),
320            #[cfg(unix)]
321            changed: (metadata.ctime(), metadata.ctime_nsec()),
322        }))
323    }
324
325    /// 修改时间与(unix 上)状态改变时间里较晚的一个;状态改变时间换算不了时是 `None`,不记缓存。
326    fn last_change(&self) -> Option<SystemTime> {
327        #[cfg(unix)]
328        {
329            let seconds = u64::try_from(self.changed.0).ok()?;
330            let nanos = u32::try_from(self.changed.1).ok()?;
331            let changed = SystemTime::UNIX_EPOCH.checked_add(Duration::new(seconds, nanos))?;
332            Some(self.modified.max(changed))
333        }
334        #[cfg(not(unix))]
335        Some(self.modified)
336    }
337}
338
339/// The framing of `dictionary_revision.rs` and `hash_dictionary_state` (bridge.cpp:441-466).
340struct Revision(Sha256);
341
342impl Revision {
343    fn new() -> Self {
344        let mut revision = Self(Sha256::new());
345        revision.text(REVISION_DOMAIN);
346        revision
347    }
348
349    fn integer(&mut self, value: u64) {
350        self.0.update(value.to_be_bytes());
351    }
352
353    fn text(&mut self, value: &str) {
354        self.integer(value.len() as u64);
355        self.0.update(value.as_bytes());
356    }
357
358    /// Negative numbers are hashed as their two's-complement `u64`, the reference's `static_cast<uint64_t>`.
359    fn record(&mut self, record: &DictionaryStateRecord) {
360        match record {
361            DictionaryStateRecord::Entry {
362                kind,
363                key,
364                value,
365                weight,
366                display,
367                deleted,
368                user_inserted,
369            } => {
370                self.text("entry");
371                self.text(kind.journal_name());
372                self.text(key);
373                self.text(value);
374                self.integer(*weight as u64);
375                self.text(display);
376                self.integer(u64::from(*deleted));
377                self.integer(u64::from(*user_inserted));
378            }
379            DictionaryStateRecord::Position {
380                context,
381                key,
382                value,
383                position,
384            } => {
385                self.text("position");
386                self.text(context);
387                self.text(key);
388                self.text(value);
389                self.integer(*position as u64);
390            }
391            DictionaryStateRecord::Selection {
392                context,
393                key,
394                value,
395                count,
396            } => {
397                self.text("selection");
398                self.text(context);
399                self.text(key);
400                self.text(value);
401                self.integer(*count as u64);
402            }
403        }
404    }
405
406    fn finish(self) -> String {
407        hex::encode(self.0.finalize())
408    }
409}
410
411/// Build a complete replacement in an exclusively created `generation/<content_id>` from `records`, consumed lazily. `None` from the iterator is verified EOF; an `Err` aborts. Duplicates and more than `maximum_records` records are rejected. Failure removes only the new directory (DS:144-231).
412///
413/// The journal lands in `generation/user` and the rebuilt dictionaries in `generation/user/dictionaries/<content_id>`; resources are copied afresh, so a replacement never inherits the dictionaries it replaces.
414pub fn stage_dictionary_state(
415    resources: &Path,
416    generation: &Path,
417    content_id: &str,
418    records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
419    maximum_records: usize,
420) -> Result<RuntimePaths> {
421    stage_dictionary_state_for(
422        resources,
423        generation,
424        content_id,
425        records,
426        maximum_records,
427        SchemeSet::ALL,
428    )
429}
430
431/// [`stage_dictionary_state`],词库按 `schemes` 准备(`prepare_runtime_paths_for`):不读 `msime-pinyin.db` 的集合只复制 `msime-english.db`,记录照收,回放时只有英文行写进词库。
432pub fn stage_dictionary_state_for(
433    resources: &Path,
434    generation: &Path,
435    content_id: &str,
436    records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
437    maximum_records: usize,
438    schemes: SchemeSet,
439) -> Result<RuntimePaths> {
440    require(resources.is_absolute() && generation.is_absolute())?;
441    require(!roots_overlap(
442        &weakly_canonical(resources)?,
443        &weakly_canonical(generation)?,
444    ))?;
445    // Exclusive creation: an existing generation, published or not, is never touched (DS:150).
446    match fs::create_dir(generation) {
447        Ok(()) => {}
448        Err(error) if error.kind() == ErrorKind::AlreadyExists => return Err(invalid_state()),
449        Err(error) => return Err(error.into()),
450    }
451    let staged = build_generation(
452        resources,
453        generation,
454        content_id,
455        records,
456        maximum_records,
457        schemes,
458    );
459    if staged.is_err() {
460        // The directory is this call's own; the caller needs the error that stopped the staging, not whether its cleanup also failed (DS:225-230).
461        let _ = fs::remove_dir_all(generation);
462    }
463    staged
464}
465
466fn build_generation(
467    resources: &Path,
468    generation: &Path,
469    content_id: &str,
470    records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
471    maximum_records: usize,
472    schemes: SchemeSet,
473) -> Result<RuntimePaths> {
474    #[cfg(unix)]
475    {
476        use std::os::unix::fs::PermissionsExt;
477        fs::set_permissions(generation, fs::Permissions::from_mode(0o700))?;
478    }
479    let user = generation.join("user");
480    fs::create_dir(&user)?;
481    let journal = user.join(assets::USER_JOURNAL);
482    // A connection of its own rather than the cached journal: this file is deleted with the directory if staging fails, and a cached handle would keep it open.
483    let mut connection = open_database(
484        &journal,
485        OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
486    )?;
487    ensure_schema(&connection).map_err(|_| invalid_state())?;
488    let transaction = connection
489        .transaction_with_behavior(TransactionBehavior::Immediate)
490        .map_err(|_| invalid_state())?;
491    {
492        // Plain INSERTs: the primary keys and `UNIQUE(context_key,position)` reject duplicate identities and slots instead of letting a later record overwrite an earlier one.
493        let mut entry = transaction
494            .prepare("INSERT INTO user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,?4,?5,?6,?7)")
495            .map_err(|_| invalid_state())?;
496        let mut position = transaction
497            .prepare("INSERT INTO fixed_candidate_positions(context_key,entry_key,value,position) VALUES(?1,?2,?3,?4)")
498            .map_err(|_| invalid_state())?;
499        let mut selection = transaction
500            .prepare("INSERT INTO candidate_selection_state(context_key,entry_key,value,selection_count) VALUES(?1,?2,?3,?4)")
501            .map_err(|_| invalid_state())?;
502        let mut count = 0usize;
503        for record in records {
504            let record = record?;
505            count += 1;
506            require(count <= maximum_records)?;
507            let inserted = match &record {
508                DictionaryStateRecord::Entry {
509                    kind,
510                    key,
511                    value,
512                    weight,
513                    display,
514                    deleted,
515                    user_inserted,
516                } => {
517                    require(
518                        bounded(key, MAX_KEY_BYTES, false)
519                            && bounded(value, MAX_VALUE_BYTES, false),
520                    )?;
521                    require(bounded(display, MAX_VALUE_BYTES, true))?;
522                    require((0..=MAX_STAGED_WEIGHT).contains(weight))?;
523                    entry.execute(params![
524                        kind.journal_name(),
525                        key,
526                        value,
527                        if *deleted { "delete" } else { "upsert" },
528                        weight,
529                        display,
530                        i64::from(*user_inserted),
531                    ])
532                }
533                DictionaryStateRecord::Position {
534                    context,
535                    key,
536                    value,
537                    position: slot,
538                } => {
539                    require(
540                        bounded(key, MAX_KEY_BYTES, false)
541                            && bounded(value, MAX_VALUE_BYTES, false),
542                    )?;
543                    require(bounded(context, MAX_KEY_BYTES, false))?;
544                    require((1..=5).contains(slot))?;
545                    position.execute(params![context, key, value, slot])
546                }
547                DictionaryStateRecord::Selection {
548                    context,
549                    key,
550                    value,
551                    count: selections,
552                } => {
553                    require(
554                        bounded(key, MAX_KEY_BYTES, false)
555                            && bounded(value, MAX_VALUE_BYTES, false),
556                    )?;
557                    require(bounded(context, MAX_KEY_BYTES, false))?;
558                    require((0..=MAX_STAGED_SELECTION_COUNT).contains(selections))?;
559                    selection.execute(params![context, key, value, selections])
560                }
561            };
562            inserted.map_err(|_| invalid_state())?;
563        }
564    }
565    transaction.commit().map_err(|_| invalid_state())?;
566    drop(connection);
567    prepare_runtime_paths_for(
568        resources,
569        &user,
570        &generation.join("cache"),
571        content_id,
572        schemes,
573    )
574}
575
576/// [`merge_dictionary_state`] 的结果:各类记录写进本机的条数,以及没写的条数和原因。
577#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
578pub struct DictionaryStateMerge {
579    /// 写进来的学习调权(`user_inserted = 0` 的 upsert)和删除记录。
580    pub entries: usize,
581    /// 写进来的固定位置。
582    pub positions: usize,
583    /// 新增或调大的选词计数。
584    pub selections: usize,
585    /// 本机已有、按规则保留本机的记录:同一个词本机已有日志行,同一个词或同一个位置本机已固定,本机的选词计数不小于记录里的。
586    pub kept: usize,
587    /// 用户自己的词(`user_inserted = 1` 的 upsert)。这里不写,由调用方经个人词库队列合并。
588    pub words: usize,
589    /// 这一代存不下的记录:没有中文词库的版本里的非英文行、编码拼不出拼音表名或词库里没有那张表的行、权重低于 1 的拼音调权(回放同样跳过它)。
590    pub skipped: usize,
591}
592
593/// 把 `records`(一份词库状态,通常是本地备份里的快照)合并进本机正在用的日志和词库,而不是像 [`stage_dictionary_state`] 那样另建一代整份替换。全部在一个 IMMEDIATE 事务里完成,任何一条记录不合规、读流失败或写库出错(磁盘满、I/O 失败等)都整体回滚。
594///
595/// 合并规则是本机优先:学习调权和删除记录只在本机日志里还没有这个词(同一 `dictionary,key,value`)时写入,写法与个人词库编辑相同(先改工作词库,再记日志,`user_inserted` 照记录保留);固定位置在本机既没有固定这个词、这个位置也没被占用时才写入;选词计数取两边较大的那个。用户自己的词不在这里写,只计数。`main_dictionary` 为假(代次里没有 `msime-pinyin.db`)时只写英文行,其余跳过。
596///
597/// 调用方必须持有词库的独占维护权:这里直接改工作词库,与会话的写入不能并发。
598pub fn merge_dictionary_state(
599    paths: &RuntimePaths,
600    main_dictionary: bool,
601    records: &mut dyn Iterator<Item = Result<DictionaryStateRecord>>,
602    maximum_records: usize,
603) -> Result<DictionaryStateMerge> {
604    use super::personal::open_edit_connection;
605    use super::replay::{apply_english, apply_pinyin, apply_simple};
606    use rusqlite::OptionalExtension;
607
608    paths.validate()?;
609    let mut connection = open_edit_connection(paths, main_dictionary)?;
610    let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
611    let mut merge = DictionaryStateMerge::default();
612    {
613        let mut existing = transaction.prepare(
614            "SELECT 1 FROM personal_journal.user_dictionary_operations WHERE dictionary=?1 AND key=?2 AND value=?3",
615        )?;
616        let mut journal = transaction.prepare(
617            "INSERT INTO personal_journal.user_dictionary_operations(dictionary,key,value,operation,weight,display,user_inserted) VALUES(?1,?2,?3,?4,?5,?6,?7)",
618        )?;
619        // 同一个词已固定,或同一个位置已被占用,都由主键和 `UNIQUE(context_key,position)` 拒绝,`OR IGNORE` 让本机的那条留下。
620        let mut position = transaction.prepare(
621            "INSERT OR IGNORE INTO personal_journal.fixed_candidate_positions(context_key,entry_key,value,position) VALUES(?1,?2,?3,?4)",
622        )?;
623        let mut selection = transaction.prepare(
624            "INSERT INTO personal_journal.candidate_selection_state(context_key,entry_key,value,selection_count) VALUES(?1,?2,?3,?4) ON CONFLICT(context_key,entry_key,value) DO UPDATE SET selection_count=excluded.selection_count WHERE excluded.selection_count>selection_count",
625        )?;
626        let mut count = 0usize;
627        for record in records {
628            let record = record?;
629            count += 1;
630            require(count <= maximum_records)?;
631            match &record {
632                DictionaryStateRecord::Entry {
633                    kind,
634                    key,
635                    value,
636                    weight,
637                    display,
638                    deleted,
639                    user_inserted,
640                } => {
641                    require(
642                        bounded(key, MAX_KEY_BYTES, false)
643                            && bounded(value, MAX_VALUE_BYTES, false)
644                            && bounded(display, MAX_VALUE_BYTES, true)
645                            && (0..=MAX_STAGED_WEIGHT).contains(weight),
646                    )?;
647                    if *user_inserted && !*deleted {
648                        merge.words += 1;
649                        continue;
650                    }
651                    if (!main_dictionary && *kind != PersonalDictionaryKind::English)
652                        || (!*deleted && *kind == PersonalDictionaryKind::Pinyin && *weight < 1)
653                    {
654                        merge.skipped += 1;
655                        continue;
656                    }
657                    let name = kind.journal_name();
658                    if existing
659                        .query_row(params![name, key, value], |_| Ok(()))
660                        .optional()?
661                        .is_some()
662                    {
663                        merge.kept += 1;
664                        continue;
665                    }
666                    let (key_bytes, value_bytes) = (key.as_bytes(), value.as_bytes());
667                    // 编码拼不出表名(`Ok(false)`),或这一代词库里没有那张表(准备语句时报 no such table,什么也没执行),这一行就存不下,计入 skipped。别的 SQL 错误(磁盘满、I/O 失败、库损坏、约束)照常上抛,整个合并回滚,调用方留着待合并的文件下次再试,而不是把没写进去的记录当成跳过。
668                    let applied = match kind {
669                        PersonalDictionaryKind::Pinyin => {
670                            apply_pinyin(&transaction, key_bytes, value_bytes, *deleted, *weight)
671                        }
672                        PersonalDictionaryKind::Wubi | PersonalDictionaryKind::Wubi98 => {
673                            apply_simple(
674                                &transaction,
675                                kind.wubi_table().unwrap_or_default(),
676                                key_bytes,
677                                value_bytes,
678                                *deleted,
679                                *weight,
680                            )
681                        }
682                        PersonalDictionaryKind::QuickPhrase => apply_simple(
683                            &transaction,
684                            "quick_parases",
685                            key_bytes,
686                            value_bytes,
687                            *deleted,
688                            *weight,
689                        ),
690                        PersonalDictionaryKind::English => apply_english(
691                            &transaction,
692                            key_bytes,
693                            value_bytes,
694                            *deleted,
695                            *weight,
696                            display.as_bytes(),
697                        ),
698                    };
699                    let applied = match applied {
700                        Ok(applied) => applied,
701                        Err(error) if missing_table(&error) => false,
702                        Err(error) => return Err(error.into()),
703                    };
704                    if !applied {
705                        merge.skipped += 1;
706                        continue;
707                    }
708                    journal.execute(params![
709                        name,
710                        key,
711                        value,
712                        if *deleted { "delete" } else { "upsert" },
713                        weight,
714                        display,
715                        i64::from(*user_inserted),
716                    ])?;
717                    merge.entries += 1;
718                }
719                DictionaryStateRecord::Position {
720                    context,
721                    key,
722                    value,
723                    position: slot,
724                } => {
725                    require(
726                        bounded(context, MAX_KEY_BYTES, false)
727                            && bounded(key, MAX_KEY_BYTES, false)
728                            && bounded(value, MAX_VALUE_BYTES, false)
729                            && (1..=5).contains(slot),
730                    )?;
731                    if position.execute(params![context, key, value, slot])? == 1 {
732                        merge.positions += 1;
733                    } else {
734                        merge.kept += 1;
735                    }
736                }
737                DictionaryStateRecord::Selection {
738                    context,
739                    key,
740                    value,
741                    count: selections,
742                } => {
743                    require(
744                        bounded(context, MAX_KEY_BYTES, false)
745                            && bounded(key, MAX_KEY_BYTES, false)
746                            && bounded(value, MAX_VALUE_BYTES, false)
747                            && (0..=MAX_STAGED_SELECTION_COUNT).contains(selections),
748                    )?;
749                    if selection.execute(params![context, key, value, selections])? == 1 {
750                        merge.selections += 1;
751                    } else {
752                        merge.kept += 1;
753                    }
754                }
755            }
756        }
757    }
758    transaction.commit()?;
759    Ok(merge)
760}
761
762/// 语句准备时报「没有这张表」:SQLite 对它用通用的 `SQLITE_ERROR`,只能看消息区分。这时语句一条也没执行,跳过这一行不会留下半截修改。
763fn missing_table(error: &rusqlite::Error) -> bool {
764    matches!(
765        error,
766        rusqlite::Error::SqliteFailure(failure, Some(message))
767            if failure.code == rusqlite::ErrorCode::Unknown && message.starts_with("no such table")
768    )
769}
770
771/// Non-empty unless `allow_empty`, at most `maximum` bytes and free of NUL (DS:82-86); `String` already guarantees UTF-8.
772fn bounded(text: &str, maximum: usize, allow_empty: bool) -> bool {
773    (allow_empty || !text.is_empty()) && text.len() <= maximum && !text.contains('\0')
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::user_dictionary::replay::tests::{sql, weight};
780    use rusqlite::Connection;
781    use std::cell::Cell;
782    use std::path::PathBuf;
783    use std::rc::Rc;
784
785    fn paths(root: &Path) -> RuntimePaths {
786        let path = |name: &str| {
787            let path = root.join(name);
788            fs::create_dir_all(&path).unwrap();
789            path
790        };
791        RuntimePaths {
792            resources: path("resources"),
793            user_data: path("user"),
794            cache: path("cache"),
795            dictionaries: path("dictionaries"),
796        }
797    }
798
799    // dictionary_revision.rs `framing_is_length_prefixed_and_big_endian`.
800    #[test]
801    fn framing_is_length_prefixed_and_big_endian() {
802        let mut actual = Revision::new();
803        actual.text("synthetic");
804        actual.integer(258);
805        let mut expected = Sha256::new();
806        let domain = b"msime-local-dictionary-state-v1";
807        expected.update((domain.len() as u64).to_be_bytes());
808        expected.update(domain);
809        expected.update(9_u64.to_be_bytes());
810        expected.update(b"synthetic");
811        expected.update([0, 0, 0, 0, 0, 0, 1, 2]);
812        assert_eq!(actual.finish(), hex::encode(expected.finalize()));
813    }
814
815    #[test]
816    fn text_boundaries_cannot_collide() {
817        let mut first = Revision::new();
818        first.text("ab");
819        first.text("c");
820        let mut second = Revision::new();
821        second.text("a");
822        second.text("bc");
823        assert_ne!(first.finish(), second.finish());
824    }
825
826    // dictionary_revision.rs `real_journal_covers_every_record_and_field_without_mutation`, on the legacy journal shape the bridge tests use.
827    #[test]
828    fn real_journal_covers_every_record_and_field_without_mutation() {
829        let root = tempfile::tempdir().unwrap();
830        let paths = paths(root.path());
831        let journal = paths.user(assets::USER_JOURNAL);
832        sql(
833            &journal,
834            "CREATE TABLE user_dictionary_operations (
835                dictionary TEXT, key TEXT, value TEXT, weight INTEGER,
836                display TEXT, operation TEXT, user_inserted INTEGER);
837             CREATE TABLE fixed_candidate_positions (
838                context_key TEXT, entry_key TEXT, value TEXT, position INTEGER);
839             CREATE TABLE candidate_selection_state (
840                context_key TEXT, entry_key TEXT, value TEXT, selection_count INTEGER);
841             INSERT INTO user_dictionary_operations VALUES
842                ('wubi', 'synthetic-key', '测试', 258, 'fixture', 'upsert', 1),
843                ('quick', 'synthetic-key', '测试', 258, 'fixture', 'upsert', 1),
844                ('pinyin', 'synthetic-key', '测试', 258, 'fixture', 'upsert', 1),
845                ('english', 'synthetic-key', '测试', -2, 'fixture', 'delete', 0);
846             INSERT INTO fixed_candidate_positions VALUES ('ctx', 'key', '测试', 3);
847             INSERT INTO candidate_selection_state VALUES ('ctx', 'key', '测试', 7);",
848        );
849        let mut expected = Revision::new();
850        for kind in ["english", "pinyin", "quick", "wubi"] {
851            expected.text("entry");
852            expected.text(kind);
853            expected.text("synthetic-key");
854            expected.text("测试");
855            expected.integer(if kind == "english" {
856                (-2_i64) as u64
857            } else {
858                258
859            });
860            expected.text("fixture");
861            expected.integer(u64::from(kind == "english"));
862            expected.integer(u64::from(kind != "english"));
863        }
864        for (kind, number) in [("position", 3), ("selection", 7)] {
865            expected.text(kind);
866            expected.text("ctx");
867            expected.text("key");
868            expected.text("测试");
869            expected.integer(number);
870        }
871        let original = fs::read(&journal).unwrap();
872        let revision = dictionary_state_revision(&paths).unwrap();
873        assert_eq!(revision, expected.finish());
874        assert_eq!(fs::read(&journal).unwrap(), original);
875        for update in [
876            "UPDATE user_dictionary_operations SET dictionary='quick' WHERE dictionary='english'",
877            "UPDATE user_dictionary_operations SET key='changed'",
878            "UPDATE user_dictionary_operations SET value='changed'",
879            "UPDATE user_dictionary_operations SET weight=259",
880            "UPDATE user_dictionary_operations SET display='changed'",
881            "UPDATE user_dictionary_operations SET operation='delete'",
882            "UPDATE user_dictionary_operations SET user_inserted=0",
883            "UPDATE fixed_candidate_positions SET context_key='changed'",
884            "UPDATE fixed_candidate_positions SET entry_key='changed'",
885            "UPDATE fixed_candidate_positions SET value='changed'",
886            "UPDATE fixed_candidate_positions SET position=4",
887            "UPDATE candidate_selection_state SET context_key='changed'",
888            "UPDATE candidate_selection_state SET entry_key='changed'",
889            "UPDATE candidate_selection_state SET value='changed'",
890            "UPDATE candidate_selection_state SET selection_count=8",
891        ] {
892            sql(&journal, update);
893            assert_ne!(
894                dictionary_state_revision(&paths).unwrap(),
895                revision,
896                "{update}"
897            );
898            fs::write(&journal, &original).unwrap();
899            assert_eq!(dictionary_state_revision(&paths).unwrap(), revision);
900        }
901    }
902
903    /// 缓存版修订号:日志没动时直接给上次的结果、不再读日志;主文件或 `-wal` 一有写入就重新计算;刚写过的日志不记缓存。`later` 当作已经过了 [`REVISION_SETTLE`]。
904    #[test]
905    fn the_cached_revision_is_reused_only_while_the_journal_is_untouched() {
906        let root = tempfile::tempdir().unwrap();
907        let paths = paths(root.path());
908        let journal = paths.user(assets::USER_JOURNAL);
909        let later = || SystemTime::now() + REVISION_SETTLE * 30;
910        let cached = |paths: &RuntimePaths| state_revision(paths, true, later).unwrap();
911        let entry = |journal: &Path| {
912            revision_cache()
913                .lock()
914                .unwrap_or_else(PoisonError::into_inner)
915                .get(journal)
916                .cloned()
917        };
918
919        let empty = dictionary_state_revision(&paths).unwrap();
920        assert_eq!(cached(&paths), empty);
921        assert_eq!(
922            entry(&journal).map(|(_, revision)| revision),
923            Some(empty.clone())
924        );
925
926        sql(
927            &journal,
928            "CREATE TABLE user_dictionary_operations (
929                dictionary TEXT, key TEXT, value TEXT, weight INTEGER,
930                display TEXT, operation TEXT, user_inserted INTEGER);
931             CREATE TABLE fixed_candidate_positions (
932                context_key TEXT, entry_key TEXT, value TEXT, position INTEGER);
933             CREATE TABLE candidate_selection_state (
934                context_key TEXT, entry_key TEXT, value TEXT, selection_count INTEGER);
935             INSERT INTO user_dictionary_operations VALUES
936                ('pinyin', 'synthetic-key', '测试', 1, '', 'upsert', 1);",
937        );
938        let first = dictionary_state_revision(&paths).unwrap();
939        assert_ne!(first, empty);
940        // 刚写过的日志:照常算出新结果,但不记缓存。
941        assert_eq!(cached_dictionary_state_revision(&paths).unwrap(), first);
942        let stamp = JournalStamp::read(&journal).unwrap();
943        assert!(entry(&journal).is_none_or(|(cached, _)| cached != stamp));
944
945        assert_eq!(cached(&paths), first);
946        // 命中时不读日志:把记下的结果换掉,缓存版原样给回来。
947        revision_cache()
948            .lock()
949            .unwrap_or_else(PoisonError::into_inner)
950            .get_mut(&journal)
951            .unwrap()
952            .1 = "synthetic".to_owned();
953        assert_eq!(cached(&paths), "synthetic");
954        // 不走缓存的版本照常读日志,并把记下的结果改回来。
955        assert_eq!(state_revision(&paths, false, later).unwrap(), first);
956        assert_eq!(cached(&paths), first);
957
958        // 回滚日志模式的提交改主文件:一行带溢出页的记录让它变长。
959        let long = "x".repeat(4_000);
960        sql(
961            &journal,
962            &format!(
963                "INSERT INTO user_dictionary_operations VALUES
964                    ('pinyin', 'long', '{long}', 1, '{long}', 'upsert', 1);"
965            ),
966        );
967        let second = cached(&paths);
968        assert_ne!(second, first);
969        assert_eq!(second, dictionary_state_revision(&paths).unwrap());
970
971        // WAL 模式的提交只追加到 `-wal`,主文件不动。
972        let writer = Connection::open(&journal).unwrap();
973        writer
974            .query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
975            .unwrap();
976        assert_eq!(cached(&paths), second);
977        writer
978            .execute_batch("UPDATE user_dictionary_operations SET weight=7 WHERE key='long'")
979            .unwrap();
980        let mut wal = journal.as_os_str().to_owned();
981        wal.push("-wal");
982        assert!(fs::metadata(&wal).unwrap().len() > 0);
983        let third = cached(&paths);
984        assert_ne!(third, second);
985        assert_eq!(third, dictionary_state_revision(&paths).unwrap());
986    }
987
988    // engine-bridge tests.rs `dictionary_revision_uses_real_journal_and_rejects_corruption`.
989    #[test]
990    fn a_missing_journal_is_not_created_and_a_corrupt_one_is_not_rewritten() {
991        let root = tempfile::tempdir().unwrap();
992        let paths = paths(root.path());
993        let before = dictionary_state_revision(&paths).unwrap();
994        assert_eq!(before.len(), 64);
995        assert_eq!(before, dictionary_state_revision(&paths).unwrap());
996        let journal = paths.user(assets::USER_JOURNAL);
997        assert!(!journal.exists());
998        fs::write(&journal, b"synthetic invalid database").unwrap();
999        let error = dictionary_state_revision(&paths).unwrap_err();
1000        assert_eq!(error.to_string(), diagnostics::INVALID_DICTIONARY_STATE);
1001        assert_eq!(fs::read(&journal).unwrap(), b"synthetic invalid database");
1002    }
1003
1004    #[test]
1005    fn unknown_kinds_text_that_is_not_utf8_and_a_stopped_export_are_invalid() {
1006        let root = tempfile::tempdir().unwrap();
1007        let paths = paths(root.path());
1008        let journal = paths.user(assets::USER_JOURNAL);
1009        sql(
1010            &journal,
1011            "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,weight INTEGER,display TEXT,operation TEXT,user_inserted INTEGER);
1012             CREATE TABLE fixed_candidate_positions(context_key TEXT,entry_key TEXT,value TEXT,position INTEGER);
1013             CREATE TABLE candidate_selection_state(context_key TEXT,entry_key TEXT,value TEXT,selection_count INTEGER);
1014             INSERT INTO user_dictionary_operations VALUES('pinyin','ni','你',1,'','upsert',1);",
1015        );
1016        let mut seen = 0;
1017        let stopped = stream_dictionary_state(&paths, &mut |_| {
1018            seen += 1;
1019            false
1020        });
1021        assert_eq!(
1022            stopped.unwrap_err().to_string(),
1023            diagnostics::INVALID_DICTIONARY_STATE
1024        );
1025        assert_eq!(seen, 1);
1026        for corruption in [
1027            "INSERT INTO user_dictionary_operations VALUES('japanese','ni','你',1,'','upsert',1)",
1028            "INSERT INTO user_dictionary_operations VALUES('wubi','ni',CAST(x'ff' AS TEXT),1,'','upsert',1)",
1029            "INSERT INTO user_dictionary_operations VALUES('wubi','ni',NULL,1,'','upsert',1)",
1030        ] {
1031            let original = fs::read(&journal).unwrap();
1032            sql(&journal, corruption);
1033            let error = dictionary_state_revision(&paths).unwrap_err();
1034            assert_eq!(error.to_string(), diagnostics::INVALID_DICTIONARY_STATE, "{corruption}");
1035            fs::write(&journal, &original).unwrap();
1036        }
1037        assert!(dictionary_state_revision(&paths).is_ok());
1038    }
1039
1040    #[test]
1041    fn oversized_state_text_is_rejected_before_export() {
1042        let root = tempfile::tempdir().unwrap();
1043        let paths = paths(root.path());
1044        let journal = paths.user(assets::USER_JOURNAL);
1045        sql(
1046            &journal,
1047            "CREATE TABLE user_dictionary_operations(dictionary TEXT,key TEXT,value TEXT,weight INTEGER,display TEXT,operation TEXT,user_inserted INTEGER);
1048             CREATE TABLE fixed_candidate_positions(context_key TEXT,entry_key TEXT,value TEXT,position INTEGER);
1049             CREATE TABLE candidate_selection_state(context_key TEXT,entry_key TEXT,value TEXT,selection_count INTEGER);",
1050        );
1051        let huge = "x".repeat(MAX_KEY_BYTES + 1);
1052        Connection::open(&journal)
1053            .unwrap()
1054            .execute(
1055                "INSERT INTO user_dictionary_operations VALUES('pinyin',?1,'你',1,'','upsert',1)",
1056                [&huge],
1057            )
1058            .unwrap();
1059
1060        assert!(dictionary_state_revision(&paths).is_err());
1061    }
1062
1063    fn resources(root: &Path) -> PathBuf {
1064        let resources = root.join("resources");
1065        fs::create_dir_all(&resources).unwrap();
1066        // Synthetic resource schema of the pinned test_dictionary_state.cpp.
1067        sql(
1068            &resources.join(assets::MAIN_DICTIONARY),
1069            "CREATE TABLE tbl_2_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);
1070             INSERT INTO tbl_2_n VALUES('ni''hao','nh','你好',100),('ni''hao','nh','拟好',80);
1071             CREATE TABLE wubi86(key TEXT,value TEXT,weight INTEGER);
1072             CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER);
1073             CREATE INDEX idx_quick_parases_key_weight ON quick_parases(key,weight DESC);",
1074        );
1075        sql(
1076            &resources.join(assets::ENGLISH_DICTIONARY),
1077            "CREATE TABLE english_words(word TEXT COLLATE BINARY NOT NULL,display TEXT NOT NULL,
1078             weight INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(word,display)) WITHOUT ROWID;
1079             CREATE TABLE en_zh_glosses(english TEXT COLLATE BINARY PRIMARY KEY,chinese_gloss TEXT NOT NULL) WITHOUT ROWID;
1080             CREATE TABLE zh_en_glosses(chinese TEXT COLLATE BINARY PRIMARY KEY,english_gloss TEXT NOT NULL) WITHOUT ROWID;
1081             PRAGMA user_version=3;",
1082        );
1083        resources
1084    }
1085
1086    fn entry(
1087        kind: PersonalDictionaryKind,
1088        key: &str,
1089        value: &str,
1090        weight: i64,
1091        display: &str,
1092        deleted: bool,
1093        user_inserted: bool,
1094    ) -> DictionaryStateRecord {
1095        DictionaryStateRecord::Entry {
1096            kind,
1097            key: key.into(),
1098            value: value.into(),
1099            weight,
1100            display: display.into(),
1101            deleted,
1102            user_inserted,
1103        }
1104    }
1105
1106    /// engine-bridge dictionary_stage/tests.rs `records()`.
1107    fn records() -> Vec<DictionaryStateRecord> {
1108        use PersonalDictionaryKind::*;
1109        vec![
1110            entry(Pinyin, "ni'hao", "你好", 0, "", true, true),
1111            entry(Pinyin, "ni'hao", "拟好", 200, "", false, false),
1112            entry(Pinyin, "ni'hao", "拟蒿", 150, "", false, true),
1113            entry(Wubi, "fixture", "合成五笔", 100, "", false, true),
1114            entry(QuickPhrase, "fixture", "合成短语", 100, "", false, true),
1115            entry(
1116                English,
1117                "cloudfixture",
1118                "Cloudfixture",
1119                100,
1120                "Cloudfixture",
1121                false,
1122                true,
1123            ),
1124            DictionaryStateRecord::Position {
1125                context: "ni'hao".into(),
1126                key: "ni'hao".into(),
1127                value: "拟蒿".into(),
1128                position: 1,
1129            },
1130            DictionaryStateRecord::Selection {
1131                context: "ni'hao".into(),
1132                key: "ni'hao".into(),
1133                value: "拟好".into(),
1134                count: 7,
1135            },
1136        ]
1137    }
1138
1139    fn stage(
1140        resources: &Path,
1141        generation: &Path,
1142        records: Vec<DictionaryStateRecord>,
1143    ) -> Result<RuntimePaths> {
1144        stage_dictionary_state(
1145            resources,
1146            generation,
1147            "fixture",
1148            &mut records.into_iter().map(Ok),
1149            100,
1150        )
1151    }
1152
1153    fn streamed(paths: &RuntimePaths) -> Vec<DictionaryStateRecord> {
1154        let mut records = Vec::new();
1155        stream_dictionary_state(paths, &mut |record| {
1156            records.push(record.clone());
1157            true
1158        })
1159        .unwrap();
1160        records
1161    }
1162
1163    // dictionary_stage/tests.rs `complete_state_rebuild_changes_real_engine_candidates_without_touching_source`: the rebuilt tables row by row; `a_session_on_a_staged_generation_offers_its_candidates` checks what a session makes of them.
1164    #[test]
1165    fn a_complete_state_rebuild_replays_into_fresh_dictionaries_without_touching_resources() {
1166        let root = tempfile::tempdir().unwrap();
1167        let resources = resources(root.path());
1168        let original = fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap();
1169        let restored = stage(&resources, &root.path().join("restored"), records()).unwrap();
1170        assert_eq!(restored.resources, resources);
1171        assert_eq!(restored.user_data, root.path().join("restored/user"));
1172        assert_eq!(restored.cache, root.path().join("restored/cache"));
1173        assert_eq!(
1174            restored.dictionaries,
1175            root.path().join("restored/user/dictionaries/fixture")
1176        );
1177        let journal = restored.user(assets::USER_JOURNAL);
1178        assert_eq!(
1179            weight(&journal, "SELECT count(*) FROM user_dictionary_operations"),
1180            Some(6)
1181        );
1182        let deletion: (String, i64) = Connection::open(&journal)
1183            .unwrap()
1184            .query_row(
1185                "SELECT operation,user_inserted FROM user_dictionary_operations WHERE value='你好'",
1186                [],
1187                |row| Ok((row.get(0)?, row.get(1)?)),
1188            )
1189            .unwrap();
1190        assert_eq!(deletion, ("delete".into(), 1));
1191        assert_eq!(
1192            weight(
1193                &journal,
1194                "SELECT selection_count FROM candidate_selection_state"
1195            ),
1196            Some(7)
1197        );
1198        assert_eq!(
1199            weight(
1200                &journal,
1201                "SELECT position FROM fixed_candidate_positions WHERE value='拟蒿'"
1202            ),
1203            Some(1)
1204        );
1205        let main = restored.dictionary(assets::MAIN_DICTIONARY);
1206        assert_eq!(
1207            weight(&main, "SELECT count(*) FROM tbl_2_n WHERE value='你好'"),
1208            Some(0)
1209        );
1210        assert_eq!(
1211            weight(&main, "SELECT weight FROM tbl_2_n WHERE value='拟好'"),
1212            Some(200)
1213        );
1214        assert_eq!(
1215            weight(&main, "SELECT weight FROM tbl_2_n WHERE value='拟蒿'"),
1216            Some(150)
1217        );
1218        assert_eq!(
1219            weight(&main, "SELECT weight FROM wubi86 WHERE value='合成五笔'"),
1220            Some(100)
1221        );
1222        assert_eq!(
1223            weight(
1224                &main,
1225                "SELECT weight FROM quick_parases WHERE value='合成短语'"
1226            ),
1227            Some(100)
1228        );
1229        assert_eq!(
1230            weight(&restored.dictionary(assets::ENGLISH_DICTIONARY), "SELECT weight FROM english_words WHERE word='cloudfixture' AND display='Cloudfixture'"),
1231            Some(100)
1232        );
1233        assert_eq!(
1234            fs::read(resources.join(assets::MAIN_DICTIONARY)).unwrap(),
1235            original
1236        );
1237
1238        // Export and restage round-trip every record, ownership of tombstones included (test_dictionary_state.cpp:86-128).
1239        let exported = streamed(&restored);
1240        assert_eq!(exported.len(), records().len());
1241        let second = stage(&resources, &root.path().join("second"), exported.clone()).unwrap();
1242        assert_eq!(streamed(&second), exported);
1243        assert_eq!(
1244            dictionary_state_revision(&second).unwrap(),
1245            dictionary_state_revision(&restored).unwrap()
1246        );
1247
1248        // An empty replacement starts from the immutable resources, never the restored overlay.
1249        let empty = stage(&resources, &root.path().join("empty"), vec![]).unwrap();
1250        assert_eq!(
1251            weight(
1252                &empty.dictionary(assets::MAIN_DICTIONARY),
1253                "SELECT weight FROM tbl_2_n WHERE value='你好'"
1254            ),
1255            Some(100)
1256        );
1257        assert!(streamed(&empty).is_empty());
1258    }
1259
1260    /// test_dictionary_state.cpp:129-147: a session opened on the staged generation reads its dictionaries and journal, so the fixed 拟蒿 leads and the deleted 你好 is gone; an empty stage offers the resource rows again.
1261    #[test]
1262    fn a_session_on_a_staged_generation_offers_its_candidates() {
1263        use crate::session::{Session, SessionOptions};
1264        let words = |paths: RuntimePaths| {
1265            let mut options = SessionOptions::new(paths);
1266            options.learning = false;
1267            let mut session = Session::new(options).unwrap();
1268            for letter in "nihao".bytes() {
1269                session.character(letter, false);
1270            }
1271            session
1272                .snapshot()
1273                .candidates
1274                .into_iter()
1275                .map(|item| item.word.into_string())
1276                .collect::<Vec<_>>()
1277        };
1278        let root = tempfile::tempdir().unwrap();
1279        let resources = resources(root.path());
1280        let restored = words(stage(&resources, &root.path().join("restored"), records()).unwrap());
1281        assert_eq!(
1282            restored.first().map(String::as_str),
1283            Some("拟蒿"),
1284            "{restored:?}"
1285        );
1286        assert!(!restored.iter().any(|word| word == "你好"), "{restored:?}");
1287        let empty = words(stage(&resources, &root.path().join("empty"), vec![]).unwrap());
1288        assert_eq!(empty.first().map(String::as_str), Some("你好"), "{empty:?}");
1289    }
1290
1291    #[test]
1292    fn an_export_is_one_consistent_read_despite_a_concurrent_writer() {
1293        let root = tempfile::tempdir().unwrap();
1294        let resources = resources(root.path());
1295        let first = stage(&resources, &root.path().join("first"), records()).unwrap();
1296        let journal = first.user(assets::USER_JOURNAL);
1297        Connection::open(&journal)
1298            .unwrap()
1299            .query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
1300            .unwrap();
1301        let mut wrote = false;
1302        let mut count = 0;
1303        stream_dictionary_state(&first, &mut |_| {
1304            count += 1;
1305            if !wrote {
1306                wrote = true;
1307                sql(
1308                    &journal,
1309                    "INSERT INTO fixed_candidate_positions VALUES('ni''hao','ni''hao','拟好',2)",
1310                );
1311            }
1312            true
1313        })
1314        .unwrap();
1315        assert_eq!(count, records().len());
1316        assert_eq!(streamed(&first).len(), records().len() + 1);
1317    }
1318
1319    // dictionary_stage/tests.rs `rejected_records_and_transport_failures_remove_only_new_generation` and test_dictionary_state.cpp:148-208.
1320    #[test]
1321    fn rejected_records_and_transport_failures_remove_only_the_new_generation() {
1322        let root = tempfile::tempdir().unwrap();
1323        let resources = resources(root.path());
1324        let target = root.path().join("rejected");
1325        let mut duplicate = records();
1326        duplicate.push(duplicate[0].clone());
1327        let mut conflicting_slot = records();
1328        conflicting_slot.push(DictionaryStateRecord::Position {
1329            context: "ni'hao".into(),
1330            key: "ni'hao".into(),
1331            value: "拟好".into(),
1332            position: 1,
1333        });
1334        let position = |slot| DictionaryStateRecord::Position {
1335            context: "ctx".into(),
1336            key: "key".into(),
1337            value: "value".into(),
1338            position: slot,
1339        };
1340        let selection = |context: &str, count| DictionaryStateRecord::Selection {
1341            context: context.into(),
1342            key: "key".into(),
1343            value: "value".into(),
1344            count,
1345        };
1346        for invalid in [
1347            duplicate,
1348            conflicting_slot,
1349            vec![position(i64::MAX)],
1350            vec![position(0)],
1351            vec![position(6)],
1352            vec![selection("ctx", -1)],
1353            vec![selection("ctx", 11)],
1354            vec![selection("ctx\0bad", 1)],
1355            vec![selection("", 1)],
1356            vec![entry(
1357                PersonalDictionaryKind::Pinyin,
1358                "ni",
1359                "你",
1360                -1,
1361                "",
1362                false,
1363                true,
1364            )],
1365            vec![entry(
1366                PersonalDictionaryKind::Pinyin,
1367                "ni",
1368                "你",
1369                100_000_001,
1370                "",
1371                false,
1372                true,
1373            )],
1374            vec![entry(
1375                PersonalDictionaryKind::Pinyin,
1376                "",
1377                "你",
1378                1,
1379                "",
1380                false,
1381                true,
1382            )],
1383            vec![entry(
1384                PersonalDictionaryKind::Pinyin,
1385                "ni",
1386                "",
1387                1,
1388                "",
1389                false,
1390                true,
1391            )],
1392            vec![entry(
1393                PersonalDictionaryKind::Pinyin,
1394                &"n".repeat(513),
1395                "你",
1396                1,
1397                "",
1398                false,
1399                true,
1400            )],
1401            vec![entry(
1402                PersonalDictionaryKind::Pinyin,
1403                "ni",
1404                &"你".repeat(1366),
1405                1,
1406                "",
1407                false,
1408                true,
1409            )],
1410            vec![entry(
1411                PersonalDictionaryKind::Pinyin,
1412                "ni",
1413                "你",
1414                1,
1415                &"x".repeat(4097),
1416                false,
1417                true,
1418            )],
1419        ] {
1420            let error = stage(&resources, &target, invalid).unwrap_err();
1421            assert_eq!(error.to_string(), diagnostics::INVALID_DICTIONARY_STATE);
1422            assert!(!target.exists());
1423        }
1424        for maximum in [0, 1] {
1425            assert!(stage_dictionary_state(
1426                &resources,
1427                &target,
1428                "fixture",
1429                &mut records().into_iter().map(Ok),
1430                maximum
1431            )
1432            .is_err());
1433            assert!(!target.exists());
1434        }
1435        // A checksum or truncation error after the last record is not a successful EOF, and its error reaches the caller unchanged.
1436        let mut stream =
1437            records()
1438                .into_iter()
1439                .map(Ok)
1440                .chain(std::iter::once(Err(EngineError::failed(
1441                    diagnostics::SNAPSHOT_STREAM_FAILED,
1442                ))));
1443        let error =
1444            stage_dictionary_state(&resources, &target, "fixture", &mut stream, 8).unwrap_err();
1445        assert_eq!(error.to_string(), diagnostics::SNAPSHOT_STREAM_FAILED);
1446        assert!(!target.exists());
1447        let error = stage_dictionary_state(
1448            &resources,
1449            &target,
1450            "../escape",
1451            &mut records().into_iter().map(Ok),
1452            8,
1453        )
1454        .unwrap_err();
1455        assert_eq!(error.to_string(), diagnostics::INVALID_RUNTIME_CONTENT_ID);
1456        assert!(!target.exists());
1457
1458        let existing = stage(&resources, &target, records()).unwrap();
1459        let revision = dictionary_state_revision(&existing).unwrap();
1460        assert!(stage(&resources, &target, vec![]).is_err());
1461        assert_eq!(dictionary_state_revision(&existing).unwrap(), revision);
1462        let inside_resources = resources.join("forbidden");
1463        assert!(stage(&resources, &inside_resources, records()).is_err());
1464        assert!(!inside_resources.exists());
1465        assert!(stage(
1466            Path::new("resources"),
1467            &root.path().join("relative"),
1468            records()
1469        )
1470        .is_err());
1471        assert!(!root.path().join("relative").exists());
1472    }
1473
1474    // dictionary_stage/tests.rs `stream_is_lazy_and_rebuild_failure_cleans_staging`.
1475    #[test]
1476    fn the_stream_is_consumed_lazily_and_a_rebuild_failure_cleans_up() {
1477        let root = tempfile::tempdir().unwrap();
1478        let resources = resources(root.path());
1479        let target = root.path().join("limited");
1480        let consumed = Rc::new(Cell::new(0));
1481        let counter = consumed.clone();
1482        let mut stream = (0..100_000).map(move |index| {
1483            counter.set(counter.get() + 1);
1484            Ok(entry(
1485                PersonalDictionaryKind::QuickPhrase,
1486                &format!("fixture{index}"),
1487                "合成短语",
1488                100,
1489                "",
1490                false,
1491                true,
1492            ))
1493        });
1494        assert!(stage_dictionary_state(&resources, &target, "fixture", &mut stream, 2).is_err());
1495        assert_eq!(consumed.get(), 3);
1496        assert!(!target.exists());
1497
1498        // Every record is valid, but copying the dictionaries fails after the stream is consumed.
1499        let missing = root.path().join("missing-resources");
1500        fs::create_dir(&missing).unwrap();
1501        assert!(stage(&missing, &target, records()).is_err());
1502        assert!(!target.exists());
1503        assert!(missing.is_dir());
1504    }
1505
1506    fn position(context: &str, key: &str, value: &str, slot: i64) -> DictionaryStateRecord {
1507        DictionaryStateRecord::Position {
1508            context: context.into(),
1509            key: key.into(),
1510            value: value.into(),
1511            position: slot,
1512        }
1513    }
1514
1515    fn selection(context: &str, key: &str, value: &str, count: i64) -> DictionaryStateRecord {
1516        DictionaryStateRecord::Selection {
1517            context: context.into(),
1518            key: key.into(),
1519            value: value.into(),
1520            count,
1521        }
1522    }
1523
1524    fn merge(
1525        paths: &RuntimePaths,
1526        records: Vec<DictionaryStateRecord>,
1527    ) -> Result<DictionaryStateMerge> {
1528        merge_dictionary_state(paths, true, &mut records.into_iter().map(Ok), 100)
1529    }
1530
1531    /// 本机已有拟好的学习调权、拟蒿这个自造词和它的固定位置,以及两条选词计数。
1532    fn local_state(resources: &Path, generation: &Path) -> RuntimePaths {
1533        use PersonalDictionaryKind::*;
1534        stage(
1535            resources,
1536            generation,
1537            vec![
1538                entry(Pinyin, "ni'hao", "拟好", 200, "", false, false),
1539                entry(Pinyin, "ni'hao", "拟蒿", 150, "", false, true),
1540                position("ni'hao", "ni'hao", "拟蒿", 1),
1541                selection("ni'hao", "ni'hao", "拟好", 7),
1542                selection("ni'hao", "ni'hao", "你好", 2),
1543            ],
1544        )
1545        .unwrap()
1546    }
1547
1548    fn backup_records() -> Vec<DictionaryStateRecord> {
1549        use PersonalDictionaryKind::*;
1550        vec![
1551            // 本机已有这个词的日志行:保留本机的 200。
1552            entry(Pinyin, "ni'hao", "拟好", 999, "", false, false),
1553            // 用户在旧设备上删掉的随包词。
1554            entry(Pinyin, "ni'hao", "你好", 0, "", true, true),
1555            // 旧设备学到的一条调权,本机还没有。
1556            entry(Pinyin, "ni'hao", "泥好", 120, "", false, false),
1557            // 用户自己的词由个人词库队列合并,这里只计数。
1558            entry(Pinyin, "ni'hao", "拟蒿", 150, "", false, true),
1559            // 编码里有空音节,拼不出表名。
1560            entry(Pinyin, "ni''hao", "坏键", 120, "", false, false),
1561            // 同一个词本机已固定在 1。
1562            position("ni'hao", "ni'hao", "拟蒿", 3),
1563            // 位置 1 已被本机占用。
1564            position("ni'hao", "ni'hao", "泥好", 1),
1565            position("ni'hao", "ni'hao", "泥好", 2),
1566            // 本机是 7,取大保留本机。
1567            selection("ni'hao", "ni'hao", "拟好", 3),
1568            // 本机是 2,取大调成 5。
1569            selection("ni'hao", "ni'hao", "你好", 5),
1570            selection("ni", "ni", "你", 4),
1571        ]
1572    }
1573
1574    #[test]
1575    fn a_merge_keeps_local_rows_takes_the_larger_count_and_writes_the_rest() {
1576        let root = tempfile::tempdir().unwrap();
1577        let resources = resources(root.path());
1578        let local = local_state(&resources, &root.path().join("local"));
1579        let merged = merge(&local, backup_records()).unwrap();
1580        assert_eq!(
1581            merged,
1582            DictionaryStateMerge {
1583                entries: 2,
1584                positions: 1,
1585                selections: 2,
1586                kept: 4,
1587                words: 1,
1588                skipped: 1,
1589            }
1590        );
1591        let journal = local.user(assets::USER_JOURNAL);
1592        let main = local.dictionary(assets::MAIN_DICTIONARY);
1593        assert_eq!(
1594            weight(&main, "SELECT weight FROM tbl_2_n WHERE value='拟好'"),
1595            Some(200)
1596        );
1597        assert_eq!(
1598            weight(&main, "SELECT count(*) FROM tbl_2_n WHERE value='你好'"),
1599            Some(0)
1600        );
1601        assert_eq!(
1602            weight(&main, "SELECT weight FROM tbl_2_n WHERE value='泥好'"),
1603            Some(120)
1604        );
1605        assert_eq!(
1606            weight(
1607                &journal,
1608                "SELECT weight FROM user_dictionary_operations WHERE value='拟好'"
1609            ),
1610            Some(200)
1611        );
1612        let rows: Vec<(String, String, i64)> = Connection::open(&journal)
1613            .unwrap()
1614            .prepare("SELECT value,operation,user_inserted FROM user_dictionary_operations WHERE value IN ('你好','泥好') ORDER BY value")
1615            .unwrap()
1616            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1617            .unwrap()
1618            .collect::<rusqlite::Result<_>>()
1619            .unwrap();
1620        assert_eq!(
1621            rows,
1622            vec![
1623                ("你好".into(), "delete".into(), 1),
1624                ("泥好".into(), "upsert".into(), 0),
1625            ]
1626        );
1627        assert_eq!(
1628            weight(
1629                &journal,
1630                "SELECT count(*) FROM user_dictionary_operations WHERE key='ni''''hao'"
1631            ),
1632            Some(0)
1633        );
1634        assert_eq!(
1635            weight(
1636                &journal,
1637                "SELECT position FROM fixed_candidate_positions WHERE value='拟蒿'"
1638            ),
1639            Some(1)
1640        );
1641        assert_eq!(
1642            weight(
1643                &journal,
1644                "SELECT position FROM fixed_candidate_positions WHERE value='泥好'"
1645            ),
1646            Some(2)
1647        );
1648        for (value, count) in [("拟好", 7), ("你好", 5), ("你", 4)] {
1649            assert_eq!(
1650                weight(
1651                    &journal,
1652                    &format!(
1653                        "SELECT selection_count FROM candidate_selection_state WHERE value='{value}'"
1654                    )
1655                ),
1656                Some(count),
1657                "{value}"
1658            );
1659        }
1660
1661        // 再合并一次什么也不改:上一次写进来的都成了本机已有的。
1662        let revision = dictionary_state_revision(&local).unwrap();
1663        let again = merge(&local, backup_records()).unwrap();
1664        assert_eq!(
1665            again,
1666            DictionaryStateMerge {
1667                entries: 0,
1668                positions: 0,
1669                selections: 0,
1670                kept: 9,
1671                words: 1,
1672                skipped: 1,
1673            }
1674        );
1675        assert_eq!(dictionary_state_revision(&local).unwrap(), revision);
1676    }
1677
1678    #[test]
1679    fn a_failed_merge_changes_nothing() {
1680        let root = tempfile::tempdir().unwrap();
1681        let resources = resources(root.path());
1682        let local = local_state(&resources, &root.path().join("local"));
1683        let main = local.dictionary(assets::MAIN_DICTIONARY);
1684        let revision = dictionary_state_revision(&local).unwrap();
1685        let dictionary = fs::read(&main).unwrap();
1686        let mut invalid = backup_records();
1687        invalid.push(position("ni", "ni", "你", 6));
1688        assert_eq!(
1689            merge(&local, invalid).unwrap_err().to_string(),
1690            diagnostics::INVALID_DICTIONARY_STATE
1691        );
1692        let mut stream = backup_records()
1693            .into_iter()
1694            .map(Ok)
1695            .chain(std::iter::once(Err(EngineError::failed(
1696                diagnostics::SNAPSHOT_STREAM_FAILED,
1697            ))));
1698        assert_eq!(
1699            merge_dictionary_state(&local, true, &mut stream, 100)
1700                .unwrap_err()
1701                .to_string(),
1702            diagnostics::SNAPSHOT_STREAM_FAILED
1703        );
1704        assert!(
1705            merge_dictionary_state(&local, true, &mut backup_records().into_iter().map(Ok), 2)
1706                .is_err()
1707        );
1708        assert_eq!(dictionary_state_revision(&local).unwrap(), revision);
1709        assert_eq!(fs::read(&main).unwrap(), dictionary);
1710    }
1711
1712    /// 词库里没有那张表的行跳过,合并照常提交;写库时别的 SQL 错误整体回滚,不当成跳过。
1713    #[test]
1714    fn a_missing_table_is_skipped_but_a_write_failure_rolls_back() {
1715        use PersonalDictionaryKind::*;
1716        let root = tempfile::tempdir().unwrap();
1717        let resources = resources(root.path());
1718        let local = local_state(&resources, &root.path().join("local"));
1719        // 测试词库里没有 wubi98 这张表。
1720        let merged = merge(
1721            &local,
1722            vec![
1723                entry(Wubi98, "wqvb", "你好", 120, "", false, false),
1724                selection("ni", "ni", "你", 4),
1725            ],
1726        )
1727        .unwrap();
1728        assert_eq!(
1729            (merged.entries, merged.selections, merged.skipped),
1730            (0, 1, 1)
1731        );
1732
1733        // 写词库时出错(这里用触发器模拟,真实情况是磁盘满或 I/O 失败):前面已经调大的选词计数也一起撤销。
1734        sql(
1735            &local.dictionary(assets::MAIN_DICTIONARY),
1736            "CREATE TRIGGER synthetic_failure BEFORE INSERT ON tbl_2_n BEGIN SELECT RAISE(ABORT,'synthetic write failure'); END;",
1737        );
1738        let revision = dictionary_state_revision(&local).unwrap();
1739        assert!(merge(
1740            &local,
1741            vec![
1742                selection("ni", "ni", "你", 9),
1743                entry(Pinyin, "ni'hao", "泥好", 120, "", false, false),
1744            ],
1745        )
1746        .is_err());
1747        assert_eq!(dictionary_state_revision(&local).unwrap(), revision);
1748        assert_eq!(
1749            weight(
1750                &local.user(assets::USER_JOURNAL),
1751                "SELECT selection_count FROM candidate_selection_state WHERE value='你'"
1752            ),
1753            Some(4)
1754        );
1755    }
1756
1757    /// 没有中文词库的代次只收英文行,其余计入 skipped,日志不动。
1758    #[test]
1759    fn a_generation_without_the_main_dictionary_merges_english_rows_only() {
1760        use PersonalDictionaryKind::*;
1761        let root = tempfile::tempdir().unwrap();
1762        let resources = resources(root.path());
1763        let local = stage(&resources, &root.path().join("local"), vec![]).unwrap();
1764        let merged = merge_dictionary_state(
1765            &local,
1766            false,
1767            &mut vec![
1768                entry(Pinyin, "ni'hao", "泥好", 120, "", false, false),
1769                entry(
1770                    English,
1771                    "cloudfixture",
1772                    "Cloudfixture",
1773                    300,
1774                    "Cloudfixture",
1775                    false,
1776                    false,
1777                ),
1778            ]
1779            .into_iter()
1780            .map(Ok),
1781            100,
1782        )
1783        .unwrap();
1784        assert_eq!((merged.entries, merged.skipped), (1, 1));
1785        assert_eq!(
1786            weight(&local.dictionary(assets::ENGLISH_DICTIONARY), "SELECT weight FROM english_words WHERE word='cloudfixture' AND display='Cloudfixture'"),
1787            Some(300)
1788        );
1789        assert_eq!(
1790            weight(
1791                &local.user(assets::USER_JOURNAL),
1792                "SELECT count(*) FROM user_dictionary_operations"
1793            ),
1794            Some(1)
1795        );
1796    }
1797
1798    // test_dictionary_state.cpp `--capacity`: a large state stages and streams back whole.
1799    #[test]
1800    fn a_large_state_stages_and_streams_back_whole() {
1801        let root = tempfile::tempdir().unwrap();
1802        let resources = resources(root.path());
1803        let total = 20_000;
1804        let mut stream = (0..total).map(|index| {
1805            Ok(entry(
1806                PersonalDictionaryKind::QuickPhrase,
1807                &format!("bulk{index}"),
1808                &format!("合成短语{index}"),
1809                100,
1810                "",
1811                false,
1812                true,
1813            ))
1814        });
1815        let staged = stage_dictionary_state(
1816            &resources,
1817            &root.path().join("capacity"),
1818            "fixture",
1819            &mut stream,
1820            DEFAULT_MAXIMUM_RECORDS,
1821        )
1822        .unwrap();
1823        let mut received = 0;
1824        stream_dictionary_state(&staged, &mut |record| {
1825            assert!(matches!(
1826                record,
1827                DictionaryStateRecord::Entry {
1828                    user_inserted: true,
1829                    weight: 100,
1830                    ..
1831                }
1832            ));
1833            received += 1;
1834            true
1835        })
1836        .unwrap();
1837        assert_eq!(received, total);
1838        assert_eq!(
1839            weight(
1840                &staged.dictionary(assets::MAIN_DICTIONARY),
1841                "SELECT count(*) FROM quick_parases"
1842            ),
1843            Some(total as i64)
1844        );
1845    }
1846}