Files
Unlucky-Launcher/crates/presenter/src/state_tests.rs
T
AskaEth 06461df91b fix(presenter): M103 r1 —— 脱敏闸门补守卫,P2 五条带走
P1(守卫缺失,产品代码本来是对的):
- 新增 agent/tests_red_line.rs:MessageAppended(绕过 agent_submit)、
  ToolCallFinished(name+summary)、非 JWT 形状的 details(键名 / 嵌套 / 数组 /
  值随 path 敏感)各一条;变异 A/H/C2 复跑确认变红
- tests.rs 拆出红线组(547 → 334 行,单文件 ≤400)

P2:
- scrub_details 补「值随 path 敏感」规则(对齐 agent::redact::redact_args)
- scrub_text 补短 Bearer 掩码(对齐 agent::redact::mask_bearer)
- pending_confirm_forces_busy_without_yolo 先 finish_stream 再断言(不再空过),
  变异 F 现在两条测试同时变红
- MessageDelta 越界下标 debug_assert;ToolCallFinished 没有 pending 时忽略,
  不再改写已收敛的历史条目
- Presenter::state_mut() 收窄成 #[cfg(test)],换成 navigate/back/toggle_ai/
  prefs_mut/agent_set_yolo/agent_set_draft 等窄入口
- agent_submit 成功后立刻 begin_turn() 置忙,事件投影完 settle_streaming() 重算,
  异步后端空窗不会再让用户点两次发送
- 补测:begin_turn 置忙、静默后端不留假忙、工具收尾不覆盖历史
2026-11-04 00:21:19 +08:00

372 lines
12 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.
//! state.rs 的单元测试(拆文件保持单文件 ≤400 行)。
use super::{grid_positions, AppState};
use crate::agent::AgentRail;
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
use crate::dto::{
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, Diagnosis, DiagnosisReport,
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind,
};
use crate::prefs::TileSize;
use crate::router::Route;
use crate::toast::Toast;
use crate::toast::ToastKind;
fn instance(id: &str, name: &str) -> InstanceSummary {
InstanceSummary {
id: id.to_string(),
name: name.to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.4".to_string(),
version_present: true,
}
}
#[test]
fn grid_positions_wrap_by_column_count() {
assert_eq!(grid_positions(0, 4), vec![]);
assert_eq!(grid_positions(3, 2), vec![(0, 0), (0, 1), (1, 0)]);
// columns == 0 时按 1 列兜底,不允许除零
assert_eq!(grid_positions(2, 0), vec![(0, 0), (1, 0)]);
}
#[test]
fn tiles_follow_tile_size_and_search() {
let mut state = AppState::new();
state.instances = vec![
instance("a", "生存"),
instance("b", "创造"),
instance("c", "Demo"),
];
assert_eq!(state.tiles().len(), 3);
assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2));
state.prefs.tile_size = TileSize::Large;
assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2));
state.prefs.tile_size = TileSize::Small;
assert_eq!(state.tiles().len(), 3);
state.instance_query = "demo".to_string();
let tiles = state.tiles();
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].instance.id, "c");
assert_eq!((tiles[0].row, tiles[0].col), (0, 0));
}
#[test]
fn tiles_reflow_when_tile_size_shrinks() {
let mut state = AppState::new();
state.instances = (0..8)
.map(|i| instance(&format!("i{i}"), &format!("实例{i}")))
.collect();
state.prefs.tile_size = TileSize::Medium;
let medium_rows = state.tiles().iter().map(|cell| cell.row).max();
state.prefs.tile_size = TileSize::Small;
let small_rows = state.tiles().iter().map(|cell| cell.row).max();
assert_eq!(medium_rows, Some(1)); // 8 / 4 列 = 2 行
assert_eq!(small_rows, Some(1)); // 8 / 6 列 = 2 行
}
#[test]
fn runtime_events_update_the_matching_instance() {
let mut state = AppState::new();
state.instances = vec![instance("a", "生存"), instance("b", "创造")];
state.apply(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id: "b".to_string(),
runtime: InstanceRuntime::Running { pid: 42 },
}));
assert_eq!(state.instances[0].runtime, InstanceRuntime::Idle);
assert_eq!(
state.instances[1].runtime,
InstanceRuntime::Running { pid: 42 }
);
assert_eq!(state.running().len(), 1);
assert_eq!(state.running()[0].id, "b");
}
#[test]
fn progress_events_only_touch_busy_instances() {
let mut state = AppState::new();
state.instances = vec![instance("a", "生存")];
state.instances[0].runtime = InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Download,
progress: crate::dto::Progress {
done: 0,
total: 100,
},
};
state.set_progress(
"a",
crate::dto::Progress {
done: 50,
total: 100,
},
);
match &state.instances[0].runtime {
InstanceRuntime::Busy { progress, .. } => {
assert!((progress.ratio() - 0.5).abs() < f32::EPSILON);
}
other => panic!("unexpected runtime: {other:?}"),
}
// 不存在的 id / 非 Busy 状态不改变任何东西
state.set_progress("missing", crate::dto::Progress { done: 1, total: 1 });
assert_eq!(state.instances.len(), 1);
}
#[test]
fn ai_stream_deltas_accumulate_and_finish() {
let mut state = AppState::new();
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::user("怎么装 1.20.4"),
)));
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::assistant_stream(),
)));
assert_eq!(state.ai.messages.len(), 2);
assert!(state.ai.streaming);
assert_eq!(state.ai.messages[1].role, ChatRole::Assistant);
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
index: 1,
delta: "先".to_string(),
}));
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
index: 1,
delta: "下载".to_string(),
}));
state.apply(AppEvent::Agent(AgentEvent::MessageFinished { index: 1 }));
assert_eq!(state.ai.messages[1].text, "先下载");
assert!(!state.ai.messages[1].streaming);
assert!(!state.ai.streaming);
}
#[test]
fn ai_session_finish_stream_clears_every_flag() {
let mut session = AgentRail::new();
session.messages.push(ChatMessage::user("hi"));
session.messages.push(ChatMessage::assistant_stream());
session.finish_stream();
assert!(session.messages.iter().all(|message| !message.streaming));
assert!(!session.streaming);
assert!(!session.busy);
}
#[test]
fn tool_call_finish_updates_the_last_matching_message() {
let mut state = AppState::new();
state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "get_config".to_string(),
summary: "读取设置".to_string(),
}));
state.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
name: "get_config".to_string(),
ok: true,
summary: "读取到 3 项".to_string(),
}));
let tool = &state.ai.messages[0];
assert_eq!(tool.role, ChatRole::Tool);
assert_eq!(tool.text, "读取到 3 项");
assert!(!tool.streaming);
assert!(tool.tool.as_ref().is_some_and(|call| call.ok));
}
#[test]
fn dialogs_and_toasts_flow_through_apply() {
let mut state = AppState::new();
state.apply(AppEvent::DialogRequested(DialogRequest {
id: 42,
source: DialogSource::UserAction,
title: "AI 想修改内存".to_string(),
body: "改为 8G".to_string(),
kind: DialogKind::Confirm {
primary: "允许".to_string(),
secondary: "拒绝".to_string(),
},
}));
state.apply(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: "下载完成".to_string(),
remaining_ms: 0,
}));
assert_eq!(state.dialogs.pending_len(), 0);
// id 归生产者:队列原样保留,DialogClosed(42) 才能命中同一条
assert_eq!(state.dialogs.current().map(|dialog| dialog.id), Some(42));
assert_eq!(state.toasts.len(), 1);
state.apply(AppEvent::DialogClosed(42));
assert!(state.dialogs.is_empty());
}
#[test]
fn navigate_to_agent_page_expands_the_ai_panel() {
let mut state = AppState::new();
state.prefs.ai_collapsed = true;
state.navigate(Route::Agent);
assert_eq!(state.route(), &Route::Agent);
assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Page);
assert!(!state.prefs.ai_collapsed);
}
#[test]
fn toggling_ai_from_the_page_form_docks_it_again() {
let mut state = AppState::new();
state.navigate(Route::Agent);
state.toggle_ai();
assert_eq!(state.route(), &Route::Instances);
assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Docked);
assert!(!state.prefs.ai_collapsed);
state.toggle_ai();
assert!(state.prefs.ai_collapsed);
}
#[test]
fn failed_event_settles_streaming_and_keeps_the_evidence() {
let mut state = AppState::new();
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::assistant_stream(),
)));
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
index: 0,
delta: "我先读一下".to_string(),
}));
state.apply(AppEvent::Agent(AgentEvent::Failed {
message: "provider 断流:read timeout".to_string(),
}));
// 抓得住:已到的增量不丢、流式标记收敛、错误进横幅也进会话历史
assert_eq!(state.ai.messages[0].text, "我先读一下");
assert!(!state.ai.messages[0].streaming);
assert!(!state.ai.streaming);
assert!(!state.ai.busy);
assert_eq!(
state.ai.error.as_deref(),
Some("provider 断流:read timeout")
);
assert_eq!(state.ai.messages[1].role, ChatRole::System);
// 抓不住:这里不判断错误归因(那是 core::diagnose 的事)
}
#[test]
fn confirm_request_keeps_the_shell_busy_until_it_resolves() {
// 分工:这条钉 state 层的「待确认 ⇒ busy」与 `ConfirmResolved` 收敛;
// presenter 层的同名守卫是 `agent::tests::pending_confirm_forces_busy_without_yolo`
// (r1 P2-2:那条曾因 ToolCallStarted 顺手置真而空过,已改成先 finish_stream 再断言)。
let mut state = AppState::new();
state.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
request: confirm_request("req-1"),
}));
assert!(state.ai.busy, "有待确认时 UI 必须禁用输入");
assert_eq!(
state.ai.confirm.as_ref().map(|request| request.id.as_str()),
Some("req-1")
);
// 别处已裁决(重放/超时):僵尸弹窗必须收掉
state.apply(AppEvent::Agent(AgentEvent::ConfirmResolved {
id: "req-1".to_string(),
}));
assert!(!state.ai.busy);
assert!(state.ai.confirm.is_none());
}
#[test]
fn unread_counts_only_while_the_rail_is_out_of_sight() {
let mut state = AppState::new();
// 展开的常驻栏在任何页面都挂着:到达即已读
assert!(state.agent_rail_visible());
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::assistant_stream(),
)));
assert_eq!(state.ai.unread, 0);
// 收成 icon 轨 = 用户看不见
state.prefs.ai_collapsed = true;
assert!(!state.agent_rail_visible());
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
index: 0,
delta: "长回复".to_string(),
}));
assert_eq!(state.ai.unread, 0, "增量属于同一条消息,不重复计数");
state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "read_game_log".to_string(),
summary: "读取日志".to_string(),
}));
assert_eq!(state.ai.unread, 1);
// 展开栏 = 用户在看它,未读立刻归零
state.toggle_ai();
assert_eq!(state.ai.unread, 0);
// 独立页形态永远算可见
state.navigate(Route::Agent);
assert!(state.agent_rail_visible());
}
#[test]
fn user_messages_never_count_as_unread() {
let mut state = AppState::new();
state.prefs.ai_collapsed = true;
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::user("我自己说的话"),
)));
assert_eq!(state.ai.unread, 0);
}
/// 待确认请求样例(High 级写操作)。
fn confirm_request(id: &str) -> crate::dto::ConfirmRequest {
crate::dto::ConfirmRequest {
id: id.to_string(),
tool: "set_config".to_string(),
summary: "把下载并发 64 → 16".to_string(),
details: serde_json::json!({ "path": "download.concurrency" }),
sensitivity: crate::dto::Sensitivity::High,
}
}
#[test]
fn toggle_nav_flips_the_pref() {
let mut state = AppState::new();
state.toggle_nav();
assert!(state.prefs.nav_collapsed);
state.toggle_nav();
assert!(!state.prefs.nav_collapsed);
}
#[test]
fn diagnosis_report_lands_on_state() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DiagnosisReady(DiagnosisReport {
session: "s1".to_string(),
diagnosis: Diagnosis {
rule: "gpu.driver_old".to_string(),
title: "显卡驱动过旧".to_string(),
confidence: 92,
hint: "升级驱动".to_string(),
fix: Some("open_driver_page".to_string()),
},
})));
assert!(state.diagnosis.is_some());
}