//! 账户页视图模型的判据(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 )); }