Files
Unlucky-Launcher/crates/presenter/src/mock.rs
T
AskaEth 73badb8a02 test(app-iced, presenter): M114 出图补三个取证态(第三方站失败 / 离线失败 / 中文错误码)
- mock 增 `authlib-failed` 场景:AuthlibLoginChanged(Failed, NotAuthlibServer),原文只进日志
- `ULL_ACCOUNT_STATE=notice` 现在同时打开离线面板(截图能看到「表单 + 失败横幅」同屏)
- 新增**截图专用**语言开关 `ULL_LANG=zh`(仅在 `--screenshot` 下生效):D30 要求错误码文案
  中英各一张图,交互模式仍钉死 Lang::En(与产品行为一致)
2026-11-05 05:10:13 +08:00

396 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! P0 假后端:用脚本化事件驱动骨架,验证 app ↔ core/agent 通道语义。
//!
//! 覆盖四条通道:实例运行状态(磁贴进度环)、AI 流式会话(含工具调用)、
//! 对话框队列(进度对话框)、Toast。阶段 B 里由真正的 core/agent host 替换
//! [`MockBackend`],[`crate::dto::Backend`] 之上的 state/reducer 不动。
//! 假数据本体在 [`crate::mock_data`]。
use std::collections::VecDeque;
use std::time::Duration;
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
use crate::dto::{
AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId,
InstanceRuntime, LaunchStage, Progress,
};
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
use crate::toast::{Toast, ToastKind};
#[path = "mock/accounts.rs"]
mod accounts_screen;
/// 启动脚本:按时间推进 download → verify → launch → running。
#[derive(Debug)]
struct LaunchScript {
id: InstanceId,
elapsed_ms: u64,
}
/// AI 流式脚本:每条提问回一段可切分的话 + 一次工具调用。
#[derive(Debug)]
struct ChatScript {
elapsed_ms: u64,
chunks: VecDeque<String>,
/// 流式助手消息在 app 会话里的序号
message_index: usize,
}
/// 假后端。
#[derive(Debug, Default)]
pub struct MockBackend {
events: VecDeque<AppEvent>,
launch: Option<LaunchScript>,
chat: Option<ChatScript>,
/// 已投递给 app 的会话消息条数(用来给 MessageDelta 算序号)
emitted_messages: usize,
next_dialog: u64,
}
impl MockBackend {
/// 预置一批假数据事件(实例/版本/日志会话)。
pub fn new() -> Self {
let mut backend = Self::default();
backend.seed();
backend
}
fn seed(&mut self) {
self.events.push_back(AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
));
self.events
.push_back(AppEvent::Core(CoreEvent::VersionListChanged(
mock_versions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogSessionsChanged(
mock_log_sessions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
self.seed_accounts();
}
/// 账户页的截图矩阵(`ULL_ACCOUNT_STATE`,见 [`accounts_screen`])。
fn seed_accounts(&mut self) {
let scenario = accounts_screen::scenario();
self.emit(AppEvent::Core(CoreEvent::AccountsChanged(
accounts_screen::accounts(&scenario),
)));
if let Some(view) = accounts_screen::device_login(&scenario) {
self.emit(AppEvent::Core(CoreEvent::DeviceLoginChanged(view)));
}
if let Some(view) = accounts_screen::authlib_login(&scenario) {
self.emit(AppEvent::Core(CoreEvent::AuthlibLoginChanged(view)));
}
if let Some(notice) = accounts_screen::notice(&scenario) {
self.emit(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(
notice,
))));
}
}
fn emit(&mut self, event: AppEvent) {
self.events.push_back(event);
}
fn next_dialog_id(&mut self) -> u64 {
self.next_dialog += 1;
self.next_dialog
}
fn script_launch(&mut self, id: InstanceId) {
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id: id.clone(),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 0,
total: 1_500,
},
},
}));
self.launch = Some(LaunchScript { id, elapsed_ms: 0 });
}
fn step_launch(&mut self, elapsed: Duration) {
let Some((id, elapsed_ms)) = self.launch.as_mut().map(|script| {
script.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
(script.id.clone(), script.elapsed_ms)
}) else {
return;
};
// 0~1.5s 下载、1.5~2.5s 校验、2.5~3s 启动、3s 后转为运行中
let runtime = if elapsed_ms < 1_500 {
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: elapsed_ms,
total: 1_500,
},
}
} else if elapsed_ms < 2_500 {
InstanceRuntime::Busy {
stage: LaunchStage::Verify,
progress: Progress {
done: elapsed_ms - 1_500,
total: 1_000,
},
}
} else if elapsed_ms < 3_000 {
InstanceRuntime::Busy {
stage: LaunchStage::Launch,
progress: Progress {
done: elapsed_ms - 2_500,
total: 500,
},
}
} else {
self.launch = None;
self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: "游戏已启动".to_string(),
remaining_ms: 0,
}));
InstanceRuntime::Running { pid: 12_345 }
};
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id,
runtime,
}));
}
fn script_chat(&mut self, text: String) {
let user_index = self.emitted_messages;
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::user(text),
)));
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::assistant_stream(),
)));
self.emitted_messages += 2;
self.chat = Some(ChatScript {
elapsed_ms: 0,
chunks: VecDeque::from(vec![
"先确认版本:".to_string(),
"官方 1.20.4 是正式版,".to_string(),
"装完再用 Fabric 加加载器。".to_string(),
]),
message_index: user_index + 1,
});
}
fn step_chat(&mut self, elapsed: Duration) {
let Some(chat) = self.chat.as_mut() else {
return;
};
chat.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
if chat.elapsed_ms < 300 {
return;
}
chat.elapsed_ms = 0;
let index = chat.message_index;
let chunk = chat.chunks.pop_front();
if let Some(delta) = chunk {
self.emit(AppEvent::Agent(AgentEvent::MessageDelta { index, delta }));
return;
}
// 流结束后补一次工具调用(工具执行在会话里可见)+ 收尾消息
self.emit(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "read_game_log".to_string(),
summary: "读取最近一次崩溃日志".to_string(),
}));
self.emit(AppEvent::Agent(AgentEvent::ToolCallFinished {
name: "read_game_log".to_string(),
ok: true,
summary: "读到 128 行,无 ERROR".to_string(),
}));
self.emit(AppEvent::Agent(AgentEvent::MessageFinished { index }));
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
role: ChatRole::Assistant,
text: "日志没有 ERROR,问题更可能在 Java 版本。".to_string(),
tool: None,
streaming: false,
})));
self.emitted_messages += 2;
self.chat = None;
}
fn script_install(&mut self, version: String) {
let id = self.next_dialog_id();
self.emit(AppEvent::DialogRequested(DialogRequest {
id,
source: DialogSource::BackgroundTask,
title: format!("正在安装 {version}"),
body: String::new(),
kind: DialogKind::Progress {
label: "下载资源".to_string(),
progress: 0.0,
},
}));
self.emit(AppEvent::DialogClosed(id));
self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: format!("{version} 安装完成"),
remaining_ms: 0,
}));
}
}
impl Backend for MockBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
self.events.drain(..).collect()
}
fn dispatch(&mut self, command: AppCommand) {
match command {
AppCommand::LaunchInstance(id) => self.script_launch(id),
AppCommand::StopInstance(id) => {
self.launch = None;
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id,
runtime: InstanceRuntime::Idle,
}));
}
AppCommand::AskAgent(text) => self.script_chat(text),
AppCommand::InstallVersion(version) => self.script_install(version),
AppCommand::SetYolo(enabled) => self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Info,
text: if enabled {
"YOLO 已开启(审计照记)"
} else {
"YOLO 已关闭"
}
.to_string(),
remaining_ms: 0,
})),
AppCommand::ListVersions(category) => {
self.emit(AppEvent::Core(CoreEvent::VersionListChanged(
crate::mock_data::mock_versions_for(category),
)));
}
AppCommand::OpenLogSession(id) => {
// P0:换会话 = 换一批假日志行 + 一条诊断结论(日志页诊断卡片可见)
self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
self.emit(AppEvent::Core(CoreEvent::DiagnosisReady(
crate::mock_data::mock_diagnosis(id),
)));
}
AppCommand::RefreshAll => self.seed_accounts(),
AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
_ => {}
}
}
fn tick(&mut self, elapsed: Duration) {
self.step_launch(elapsed);
self.step_chat(elapsed);
}
}
#[cfg(test)]
mod tests {
use super::MockBackend;
use crate::dto::{AgentEvent, AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime};
#[test]
fn seeds_instances_and_versions() {
let mut backend = MockBackend::new();
let events = backend.drain_events();
// 实例 / 版本 / 会话 / 日志行 **+ 账户页的种子**(M114:账户列表 + 可选的登录面板态)
assert!(
events.len() >= 5,
"至少含账户列表,实际 {} 条",
events.len()
);
assert!(events
.iter()
.all(|event| matches!(event, AppEvent::Core(_))));
assert!(backend.drain_events().is_empty());
}
#[test]
fn launching_emits_progress_then_running() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::LaunchInstance("survival".to_string()));
let first = backend.drain_events();
assert!(matches!(
first[0],
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
));
backend.tick(std::time::Duration::from_millis(1_600));
let verify = backend.drain_events();
match &verify[0] {
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Busy { stage, .. },
..
}) => assert_eq!(*stage, crate::dto::LaunchStage::Verify),
other => panic!("unexpected event: {other:?}"),
}
backend.tick(std::time::Duration::from_millis(2_000));
let done = backend.drain_events();
assert!(done.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Running { .. },
..
})
)));
}
#[test]
fn stopping_cancels_the_launch_script() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::LaunchInstance("a".to_string()));
backend.drain_events();
backend.dispatch(AppCommand::StopInstance("a".to_string()));
let events = backend.drain_events();
assert!(matches!(
events[0],
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Idle,
..
})
));
// 脚本已取消:再推进时间也不会再产出事件
backend.tick(std::time::Duration::from_millis(5_000));
assert!(backend.drain_events().is_empty());
}
#[test]
fn asking_the_agent_streams_and_finishes() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::AskAgent("装哪个版本".to_string()));
let start = backend.drain_events();
assert_eq!(start.len(), 2);
let mut deltas = 0;
for _ in 0..6 {
backend.tick(std::time::Duration::from_millis(400));
let events = backend.drain_events();
deltas += events
.iter()
.filter(|event| matches!(event, AppEvent::Agent(AgentEvent::MessageDelta { .. })))
.count();
}
assert_eq!(deltas, 3);
}
}