Files
Unlucky-Launcher/crates/app-iced/src/pages/account/tests.rs
T
AskaEth af39efd23b fix(presenter,app-iced,i18n): M132 缺 MSA client id 上屏成「未配置 + 怎么补」
缺 client id 原来整条链路只给 `LoginFailure::Other`:界面画「Sign-in failed /
完整原因在启动器日志里 / Code: other」,用户得翻日志才知道要设 `ULL_MSA_CLIENT_ID`。

落点定在**账户页登录面板**,不是启动流程:这个失败只发生在 `microsoft_auth()`(两条
登录流的入口);没配 id 的发布版建不出微软账户,`identity::resolve` 那条启动路径用户
撞不到(只有「有 id 的构建里登录过、之后 id 被摘掉」才可达)。

- presenter:新增 `LoginFailure::MsaNotConfigured`(`msa_not_configured`)。`microsoft_auth`
  改为回 `AuthResult`,`AuthError::Unsupported`(`from_env` 唯一的产出点,零网络)单独成码,
  其余失败照旧走 `classify`。诊断原文仍只进日志。
- i18n:账户域新增 `AccountLoginNotConfigured`(标题)/ `AccountFailureMsaNotConfigured`
  (正文:设 `ULL_MSA_CLIENT_ID=<id>` 或构建时注入,改完重启),golden 同步。
- app-iced:`failure_title_key` 给这一类专属标题;正文按码查表;这一类**不给「重试」**
  (进程内的环境变量改不了,重试必然是同一个错)。
- 测试:`a_missing_client_id_tells_the_user_what_to_configure` 断言 UI 模型里出现「未配置」
  + 变量名 + `<id>` 占位 + `msa_not_configured`,且没有那句「去翻日志」;反向锚
  `a_network_failure_does_not_look_like_a_missing_config` 与
  `only_a_missing_client_id_becomes_the_config_gap_code` 钉住别的失败不许跟着变。

为什么这一类可以上屏:上屏的是 catalog 固定文案(无凭据、无 token、无内部细节),
`DevicePanel::Failed` 里没有 `message`/`detail` 字段,原文在类型上就画不出来;
理由是它的原因本身就是用户的下一步。M117/M119 的「诊断不上屏」约定不动。
2026-11-05 20:08:52 +08:00

382 lines
16 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)**:模型 + i18n 查表这一路又出现中文(视图层由类型禁画原文,见 `tests_text` 的边界说明) |
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」/ 倒计时不是人话格式 |
//! | `a_missing_client_id_tells_the_user_what_to_configure` | **M132**:缺 client id 又被画成通用失败(用户得翻日志才知道要设变量) |
//! | `a_network_failure_does_not_look_like_a_missing_config` | **M132 反向锚**:网络失败被误标成「未配置」,提示变噪音 |
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::{DevicePanel, Form, 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`)。
/// 视图层不在此列:面板态里已无 `message`/`detail` 字段,想画原文是编译错误。
#[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。
///
/// 抓得住:`failure_body_key` 的码→key 映射被摘/接错;DTO 或状态层把中文混进模型字段。
/// 抓不住(复审 r2 P2-2 实测过):视图层把原文画上屏——这条断言是**平行实现**。
/// 那道回归改由**类型**拦:`DevicePanel`/`AuthlibPanel` 已无 `message`/`detail` 字段。
/// 也抓不住: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}");
}
/// **M132 的主锚**:缺 client id 时**UI 模型**给出的结论必须是「这一项没配 + 怎么补」。
///
/// 抓得住:这一类失败又退回通用的「登录失败 / 完整原因在启动器日志里」(原 bug),
/// 或者文案只说「缺 id」却不告诉用户去设哪个变量。
/// 抓不住:排版与配色(那靠截图);也不证明视图真的照模型画(见 `tests_text` 的边界说明)。
#[test]
fn a_missing_client_id_tells_the_user_what_to_configure() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
// 后端原文照旧进这里(只该进日志):界面上一句都不许出现。
DeviceLoginView::failed_as(
LoginFailure::MsaNotConfigured,
"微软登录未配置:缺少 Azure 公共客户端 id",
),
)));
let text = on_screen(&build(&state, &Form::new()));
assert!(
text.contains("not configured"),
"标题要说清「未配置」:{text}"
);
assert!(
text.contains("ULL_MSA_CLIENT_ID"),
"要给出具体要设的变量名:{text}"
);
assert!(text.contains("<id>"), "示例只许用占位,不许写值:{text}");
assert!(text.contains("msa_not_configured"), "{text}");
assert!(!has_cjk(&text), "英文界面里出现了中文:{text}");
// 不许退回「去翻日志」那句:那正是用户报的那个 bug。
assert!(
!text.contains("full reason is in the launcher log"),
"{text}"
);
}
/// **反向锚(M132)**:普通网络失败不许也变成「未配置」——否则提示变噪音,用户会学会无视它。
#[test]
fn a_network_failure_does_not_look_like_a_missing_config() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
DeviceLoginView::failed_as(LoginFailure::Unreachable, "连不上服务器:dns error"),
)));
let text = on_screen(&build(&state, &Form::new()));
assert!(text.contains("unreachable"), "{text}");
assert!(text.to_lowercase().contains("could not reach"), "{text}");
assert!(!text.to_lowercase().contains("not configured"), "{text}");
assert!(!text.contains("ULL_MSA_CLIENT_ID"), "{text}");
assert!(text.contains("Sign-in failed"), "标题仍是通用失败:{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
));
}