Files
Unlucky-Launcher/crates/agent/src/testing.rs
T
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

191 lines
6.5 KiB
Rust

//! 测试替身:五个端口的 **mock 实现**(无盘、无网络、无时间依赖)。
//!
//! 放在库内(而不是只在 `#[cfg(test)]`)是刻意的:`crates/app` 装配时也要能给
//! 自己的单测造一套「无盘」环境,且 P1/M39 的 MCP 契约测试要复用同一批假数据。
//!
//! 按端口拆文件(`port.rs` 挨 400 行上限的同款理由):`settings` 配置、`sessions`
//! 会话日志在下面,`game` 实例/Java/账户与 `diagnose` 诊断各一个子模块。
//! 对外名字不变,`unlucky_agent::testing::MockSettings` 之类照旧可用(走 re-export)。
pub mod diagnose;
pub mod game;
pub mod settings;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Value};
use crate::port::{GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult};
pub use diagnose::MockDiagnose;
pub use game::{MockAccounts, MockGame};
pub use settings::MockSettings;
/// 内存会话端口。
#[derive(Debug, Default)]
pub struct MockSessions {
sessions: Vec<GameSessionSummary>,
events: BTreeMap<String, Vec<LogEvent>>,
}
impl MockSessions {
/// 空数据。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 加一个会话及其事件流。
pub fn add(&mut self, summary: GameSessionSummary, events: Vec<LogEvent>) {
self.events.insert(summary.session_id.clone(), events);
self.sessions.push(summary);
}
/// 便捷:造一个崩溃会话(`exit_code=1` + stderr + crash_found)。
#[must_use]
pub fn crashing_session(session_id: &str) -> (GameSessionSummary, Vec<LogEvent>) {
let summary = GameSessionSummary {
session_id: session_id.to_string(),
instance_id: Some("inst-1".to_string()),
instance_name: Some("1.20.1 Forge".to_string()),
game_version: Some("1.20.1".to_string()),
started_at: Some("2026-10-29T12:45:01.123Z".to_string()),
ended_at: Some("2026-10-29T12:46:31.123Z".to_string()),
duration_ms: 90_000,
exit_code: Some(1),
exit_type: Some("application_error".to_string()),
silent_failure: false,
window_detected: true,
event_count: 5,
crash_found: true,
diagnosis_top: Some("forge_missing_library".to_string()),
};
let events = vec![
event(
0,
"stdout",
json!({ "line": "Starting Minecraft 1.20.1", "level": "info" }),
),
event(
1,
"stderr",
json!({ "line": "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", "level": "error" }),
),
event(
2,
"crash_found",
json!({ "source": "crash_report", "path": r"C:\Users\aska\.minecraft\crash-reports\crash-1.txt", "excerpt": "Description: Mod loading failure" }),
),
event(
3,
"exit",
json!({ "code": 1, "exit_type": "application_error", "silent_failure": false }),
),
event(
4,
"session_end",
json!({ "reason": "exit", "duration_ms": 90000 }),
),
];
(summary, events)
}
}
fn event(seq: u64, kind: &str, payload: Value) -> LogEvent {
LogEvent {
seq,
ts: format!("2026-10-29T12:45:0{seq}.000Z"),
t_ms: seq * 1_000,
kind: kind.to_string(),
payload,
}
}
impl GameSessionPort for MockSessions {
fn list_sessions(
&self,
limit: usize,
instance_id: Option<&str>,
) -> PortResult<Vec<GameSessionSummary>> {
let mut out: Vec<GameSessionSummary> = self
.sessions
.iter()
.filter(|s| instance_id.is_none_or(|id| s.instance_id.as_deref() == Some(id)))
.cloned()
.collect();
out.sort_by(|a, b| b.started_at.cmp(&a.started_at));
out.truncate(limit);
Ok(out)
}
fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult<LogPage> {
let events = self
.events
.get(session_id)
.ok_or_else(|| format!("会话 `{session_id}` 不存在"))?;
let mut kind_counts = BTreeMap::new();
for event in events {
*kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1;
}
let matched: Vec<&LogEvent> = events
.iter()
.filter(|e| query.from_seq.is_none_or(|from| e.seq >= from))
.filter(|e| query.to_seq.is_none_or(|to| e.seq <= to))
.filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind))
.collect();
let max = if query.max_events == 0 {
matched.len()
} else {
query.max_events
};
let truncated = matched.len() > max;
Ok(LogPage {
session_id: session_id.to_string(),
total_events: events.len() as u64,
matched_events: matched.len() as u64,
truncated,
kind_counts,
first_ts: events.first().map(|e| e.ts.clone()),
last_ts: events.last().map(|e| e.ts.clone()),
events: matched.into_iter().take(max).cloned().collect(),
})
}
}
/// 一次性造好全部端口的 [`crate::port::Ports`](含一个崩溃会话,够跑通对话与日志工具)。
#[must_use]
pub fn mock_ports() -> (crate::port::Ports, std::sync::Arc<MockSettings>) {
use std::sync::Arc;
let settings = Arc::new(MockSettings::new());
let (summary, events) = MockSessions::crashing_session("20261029T124501-9f3c2a");
let mut sessions = MockSessions::new();
sessions.add(summary, events);
let ports = crate::port::Ports {
setting: settings.clone(),
sessions: Arc::new(sessions),
diagnose: Arc::new(MockDiagnose::new()),
game: Arc::new(MockGame::new()),
accounts: Arc::new(MockAccounts::new()),
};
(ports, settings)
}
/// 测试里常用的「全部工具名」集合(契约测试与 MCP 投影比对用)。
#[must_use]
pub fn expected_v1_tool_names() -> BTreeSet<&'static str> {
[
"get_config",
"get_config_schema",
"set_config",
"list_game_sessions",
"read_game_log",
"diagnose_crash",
"list_instances",
"list_versions",
"get_java_status",
"list_accounts",
]
.into_iter()
.collect()
}