Files
Unlucky-Launcher/crates/app-iced/src/pages/account/tests.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

329 lines
13 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.
//! 账户页视图模型的判据(M114 的核心断言都在这里)。
//!
//! | 用例 | 抓得住什么 |
//! |---|---|
//! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 |
//! | `a_failure_with_a_provider_code_renders_english_and_the_code` | **语言泄漏(r1 p1)**:后端中文诊断串又直接上屏了 |
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」/ 倒计时不是人话格式 |
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{
AccountKind, AccountNotice, AccountNoticeCode, AccountView, AppEvent, AuthlibLoginView,
CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure, SkinImage,
};
use unlucky_presenter::state::AppState;
use super::avatar::Cache;
use super::model::{AuthlibPanel, DevicePanel, Form, Hint, Page, Panel, Track};
use super::tests_text::{has_cjk, on_screen};
fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
AccountView {
id: format!("account:{name}"),
name: name.to_string(),
kind,
profile_id: "0f8f1d3c4b5a69788796a5b4c3d2e1f0".to_string(),
login_name: String::new(),
created: "2026-10-31 20:12".to_string(),
active,
server_url: String::new(),
skin: None,
}
}
fn build(state: &AppState, form: &Form) -> Page {
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
}
const JWT: &str =
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
/// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 状态与视图模型里都不许出现。
///
/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`),
/// 或页面把 DTO 的错误原文直接搬上屏。
#[test]
fn a_jwt_injected_into_events_never_reaches_the_page() {
let mut state = AppState::new();
state.accounts = vec![account("Steve", AccountKind::Microsoft, true)];
// **绕过构造器**直接摆一个带 JWT 的 DTO:模拟「将来某个新构造器忘了脱敏」/「后端把原文塞进来」。
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message: format!("微软返回错误:accessToken={JWT} refresh_token={JWT}"),
failure: Some(LoginFailure::Denied),
..DeviceLoginView::new()
},
)));
state.apply(AppEvent::Core(CoreEvent::AuthlibLoginChanged(
AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
server: "https://littleskin.cn/api/yggdrasil/".to_string(),
server_name: String::new(),
message: format!("服务器回包异常 {JWT}"),
failure: Some(LoginFailure::Denied),
account_name: String::new(),
},
)));
// ① 状态层已经脱敏(这一条抓得住「摘掉 state 层 redact」的变异)
assert!(
!state.device_login.message.contains("eyJ"),
"{}",
state.device_login.message
);
assert!(
!state.authlib_login.message.contains("eyJ"),
"{}",
state.authlib_login.message
);
// ② 上屏文案更不可能有它(页面根本不画原文)
let text = on_screen(&build(&state, &Form::new()));
assert!(!text.contains("eyJ"), "视图里出现了 JWT:{text}");
assert!(!text.contains("⟪redacted⟫"), "脱敏占位符也不该上屏:{text}");
}
/// **语言泄漏(复审 r1 p1)**:带 AADSTS 码的失败事件 → 英文视图里不许有中文,且要带 code。
///
/// 抓得住:有人又把 `view.message`(core 的中文诊断串)画回面板上;或者码→i18n 的映射被摘。
/// 抓不住:catalog 里某条英文译文本身写得差(那只能人工审)。
#[test]
fn a_failure_with_a_provider_code_renders_english_and_the_code() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Failed,
message:
"微软拒绝了这次登录:AADSTS7000218(HTTP 403)—— 应用白名单还没批准,或网络被拦截"
.to_string(),
failure: Some(LoginFailure::NotWhitelisted),
..DeviceLoginView::new()
},
)));
let page = build(&state, &Form::new());
let text = on_screen(&page);
assert!(!has_cjk(&text), "英文界面里出现了中文:{text}");
assert!(
text.contains("whitelist_pending"),
"面板要给稳定 code:{text}"
);
assert!(
text.to_lowercase().contains("approved"),
"要给出可读的英文原因:{text}"
);
// 第三方轨的失败同样只有 i18n + code
state.apply(AppEvent::Core(CoreEvent::AuthlibLoginChanged(
AuthlibLoginView {
phase: DeviceLoginPhase::Failed,
server: "https://example.invalid/".to_string(),
server_name: String::new(),
message: "服务器不可达:dns error".to_string(),
failure: Some(LoginFailure::Unreachable),
account_name: String::new(),
},
)));
let text = on_screen(&build(&state, &Form::new()));
assert!(!has_cjk(&text), "{text}");
assert!(text.contains("unreachable"), "{text}");
}
/// 空态:**三条轨道都有入口**,不是一张白页;且三张卡的标题各不相同。
#[test]
fn an_empty_account_list_offers_all_three_tracks() {
let state = AppState::new();
let page = build(&state, &Form::new());
assert!(page.is_empty());
assert_eq!(page.panel, Panel::None);
assert!(page.active_row().is_none());
let mut keys: Vec<&str> = Track::ALL
.iter()
.map(|track| track.title_key().name())
.collect();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), 3);
}
/// 设备码:**倒计时是人话格式(mm:ss)**,过期后是「已过期」而不是继续转圈。
#[test]
fn the_device_code_panel_shows_a_countdown_and_expires() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, ""),
)));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Code {
code,
remaining,
expired,
..
} => {
assert_eq!(code, "ABCD-EFGH");
assert_eq!(*remaining, Some(900));
assert!(!expired);
}
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
}
let text = on_screen(&page);
assert!(text.contains("15:00"), "倒计时要画成 mm:ss:{text}");
assert!(!text.contains("900s"), "不再是 `900s left`:{text}");
// 起点往前挪 20 分钟 ⇒ 过期
state.device_login_started =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1_200));
let page = build(&state, &Form::new());
match &page.device {
DevicePanel::Code {
remaining, expired, ..
} => {
assert_eq!(*remaining, Some(0));
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
}
other => panic!("过期态仍是设备码面板,实际 {other:?}"),
}
// 授权码流也走同一条倒计时(没有用户码时是 Browser 面板)
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::browser_waiting("https://login.microsoftonline.com/x", 300, None),
)));
let page = build(&state, &Form::new());
assert!(matches!(page.device, DevicePanel::Browser { .. }));
}
/// 没有皮肤 = 首字母块(不是白块、不是假皮肤);坏图也必须回落;无皮肤时**明说**。
#[test]
fn a_third_party_account_falls_back_to_the_monogram() {
let mut state = AppState::new();
state.accounts = vec![
account("Alex", AccountKind::Authlib, true),
account("Steve", AccountKind::Microsoft, false),
];
let page = build(&state, &Form::new());
assert!(
page.rows.iter().all(|row| row.head.is_none()),
"没有皮肤就不许有图"
);
assert!(page.rows.iter().all(|row| !row.monogram.is_empty()));
assert!(page.active_skin().is_none(), "皮肤预览区没有图就不画图");
let text = on_screen(&page);
assert!(!has_cjk(&text));
// 塞一张坏图:解码失败 → 仍然回落到首字母块(不 panic、不白块)
state.accounts[0].skin = Some(SkinImage {
png: b"not a png".to_vec(),
source: "test".to_string(),
});
let page = build(&state, &Form::new());
assert!(page.rows[0].head.is_none());
}
/// 离线档案的说明**必须**带「无法连接启用正版验证的服务器」这条限定(中英都要)。
#[test]
fn the_offline_track_keeps_the_agreed_wording() {
for lang in Lang::ALL {
let catalog = lang.catalog();
for key in [Key::AccountTrackOfflineBody, Key::AccountOfflineWarning] {
let text = catalog.text(key);
assert!(
text.contains("cannot join servers that require a genuine Minecraft account")
|| text.contains("无法连接启用正版验证的服务器"),
"{} 的 {key:?} 丢了「仅本地/隔离网络测试用」口径:{text}",
lang.tag()
);
assert!(
!text.contains("migration") && !text.contains("迁移"),
"不许改写成「兼容旧启动器迁移」:{text}"
);
}
}
}
/// **离线轨的失败必须可见**(复审 r1 P2-2):横幅进模型、文案走 i18n、原文不上屏。
#[test]
fn a_failed_offline_add_is_visible() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(
AccountNotice::new(
AccountNoticeCode::OfflineNameInvalid,
"ab",
"离线名字必须是 3–16 位字母/数字/下划线,收到 \"ab\"",
),
))));
let page = build(&state, &Form::new());
let notice = page.notice.as_ref().expect("失败要有横幅");
assert_eq!(notice.code, AccountNoticeCode::OfflineNameInvalid);
assert!(notice.failure, "失败横幅要用 error 配色");
let text = on_screen(&page);
assert!(text.contains("3–16"), "要说清规则:{text}");
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
}
/// 表单状态:三条轨道各开各的,密码关面板即清,三卡默认收起。
#[test]
fn the_form_tracks_are_independent() {
let mut form = Form::new();
assert!(!form.section_open, "有账号时三卡默认收起(P2-4)");
form.open(Track::Offline);
assert_eq!(form.panel, Panel::Offline);
assert!(!form.offline_ready(), "空名字不能提交");
form.offline_name = "ab".to_string();
assert!(
!form.offline_ready(),
"太短也不许提交(与 host 同一条规则)"
);
assert!(
!unlucky_presenter::dto::valid_offline_name("ab"),
"UI 判据 = host 那份规则"
);
form.offline_name = "Alex".to_string();
assert!(form.offline_ready());
form.open(Track::Authlib);
assert!(!form.authlib_ready(), "地址/登录名/密码缺一不可");
form.server = "https://littleskin.cn/api/yggdrasil/".to_string();
form.login = "alex".to_string();
form.password = "hunter2".to_string();
assert!(form.authlib_ready());
form.clear_secret();
assert!(form.password.is_empty());
assert!(!form.authlib_ready());
}
/// 登录阶段的可判据性:Requesting / Waiting / Failed 各自映射到不同面板。
#[test]
fn login_phases_map_to_distinct_panels() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView {
phase: DeviceLoginPhase::Requesting,
message: "正在向微软申请设备码…".to_string(),
..DeviceLoginView::new()
},
)));
let page = build(&state, &Form::new());
assert!(matches!(page.device, DevicePanel::Requesting { .. }));
// 进行中也只有 i18n(不再复读 presenter 的中文状态串)
assert!(!has_cjk(&on_screen(&page)));
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed("HTTP 403"),
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Failed { .. }
));
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::new(),
)));
assert!(matches!(
build(&state, &Form::new()).device,
DevicePanel::Hidden
));
}