Files
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

129 lines
3.9 KiB
Rust

//! 保存通道:200ms 去抖、同路径覆盖、攒批落盘、后台线程 flush/关停。
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use unlucky_core::setting::error::SettingResult;
use unlucky_core::setting::save::{CoalescingWriter, DebounceQueue, JobSink, DEFAULT_DEBOUNCE};
use unlucky_core::setting::save_worker::SaveWorker;
use crate::common::TempDir;
/// 记录下来的 (路径, 内容) 列表。
type Written = Vec<(PathBuf, Vec<u8>)>;
#[derive(Clone, Default)]
struct RecordingSink {
written: Arc<Mutex<Written>>,
}
impl RecordingSink {
fn entries(&self) -> Vec<(PathBuf, Vec<u8>)> {
self.written.lock().expect("锁").clone()
}
}
impl JobSink for RecordingSink {
fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> {
self.written
.lock()
.expect("锁")
.push((path.to_path_buf(), payload.to_vec()));
Ok(())
}
}
#[test]
fn default_debounce_is_200ms() {
assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(200));
}
#[test]
fn waits_for_the_window_before_writing() {
let start = Instant::now();
let mut queue = DebounceQueue::<String>::new(DEFAULT_DEBOUNCE);
assert!(queue.is_empty());
queue.enqueue("a".to_owned(), b"1".to_vec(), start);
queue.enqueue("b".to_owned(), b"2".to_vec(), start);
assert_eq!(queue.len(), 2);
assert!(queue.contains(&"a".to_owned()));
assert_eq!(queue.next_due(), Some(start + DEFAULT_DEBOUNCE));
assert!(queue
.drain_due(start + Duration::from_millis(100))
.is_empty());
let due = queue.drain_due(start + DEFAULT_DEBOUNCE);
assert_eq!(due.len(), 2);
assert_eq!(due[0].0, "a", "同一批按键排序,写盘顺序稳定");
assert!(queue.is_empty());
}
#[test]
fn same_key_collapses_and_keeps_first_deadline() {
let start = Instant::now();
let mut queue = DebounceQueue::<String>::new(DEFAULT_DEBOUNCE);
queue.enqueue("a".to_owned(), b"1".to_vec(), start);
queue.enqueue(
"a".to_owned(),
b"2".to_vec(),
start + Duration::from_millis(150),
);
assert_eq!(queue.len(), 1);
assert_eq!(
queue.next_due(),
Some(start + DEFAULT_DEBOUNCE),
"窗口从首次改动开始算,避免连续改动无限推迟写盘"
);
assert_eq!(
queue.drain_due(start + DEFAULT_DEBOUNCE),
vec![("a".to_owned(), b"2".to_vec())]
);
}
#[test]
fn coalescing_writer_batches_into_one_flush() {
let sink = RecordingSink::default();
let mut writer = CoalescingWriter::new(sink.clone(), DEFAULT_DEBOUNCE);
let start = Instant::now();
writer.queue("/tmp/a.json", b"1".to_vec(), start);
writer.queue(
"/tmp/a.json",
b"2".to_vec(),
start + Duration::from_millis(50),
);
writer.queue("/tmp/b.json", b"3".to_vec(), start);
assert_eq!(writer.pending_len(), 2);
assert!(!writer.is_empty());
assert_eq!(writer.flush_due(start).expect("未到窗口"), 0);
assert_eq!(
writer.flush_due(start + DEFAULT_DEBOUNCE).expect("到窗口"),
2
);
assert!(writer.is_empty());
assert_eq!(writer.flush_all().expect("空队列兜底"), 0);
let written = sink.entries();
assert_eq!(written.len(), 2);
assert_eq!(written[0].0, PathBuf::from("/tmp/a.json"));
assert_eq!(written[0].1, b"2".to_vec(), "只写最新内容");
}
#[test]
fn save_worker_flushes_and_shuts_down() {
let temp = TempDir::new("save-worker");
let target = temp.child("config/launcher.json");
let mut worker = SaveWorker::spawn(Duration::from_millis(30)).expect("起线程");
worker.queue(&target, b"{\"a\":1}").expect("入队");
worker.queue(&target, b"{\"a\":2}").expect("入队");
worker.flush().expect("同步落盘");
assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}");
worker.shutdown().expect("关停");
}