diff --git a/crates/app-iced/src/pages/account/login.rs b/crates/app-iced/src/pages/account/login.rs index 17a9455..6d6c718 100644 --- a/crates/app-iced/src/pages/account/login.rs +++ b/crates/app-iced/src/pages/account/login.rs @@ -278,33 +278,36 @@ fn countdown_line<'a>( /// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。 /// -/// 两条修订叠在这里: +/// 三点修订叠在这里: /// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏; /// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边, -/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小)。 +/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小); +/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的 +/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。 fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> { let body = catalog.text(failure_body_key(failure)).to_string(); let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]); - let actions = vec![ - buttons::tonal( + let mut actions: Vec> = Vec::new(); + if !matches!(failure, LoginFailure::MsaNotConfigured) { + actions.push(buttons::tonal( ui, Button::new( catalog.text(Key::AccountDeviceRetry), Account::StartDevice.into_page(), ), + )); + } + actions.push(buttons::text( + ui, + Button::new( + catalog.text(Key::AccountDialogClose), + Account::Close.into_page(), ), - buttons::text( - ui, - Button::new( - catalog.text(Key::AccountDialogClose), - Account::Close.into_page(), - ), - ), - ]; + )); super::widgets::alert( ui, super::widgets::Tone::Error, - catalog.text(Key::AccountLoginFailed), + catalog.text(failure_title_key(failure)), body, Some(code), actions, @@ -347,6 +350,18 @@ pub(super) fn failure_body_key(failure: LoginFailure) -> Key { LoginFailure::BadCredentials => Key::AccountFailureBadCredentials, LoginFailure::NoProfile => Key::AccountFailureNoProfile, LoginFailure::StoreFailed => Key::AccountFailureStore, + LoginFailure::MsaNotConfigured => Key::AccountFailureMsaNotConfigured, LoginFailure::Other => Key::AccountFailureOther, } } + +/// 原因码 → i18n **标题** key。 +/// +/// 只有「缺 client id」有专属标题(M132):标题写着 `Sign-in failed` 的话,用户会以为是自己 +/// 的账号/网络出了问题,而不会被指向那一项要补的配置。 +pub(super) fn failure_title_key(failure: LoginFailure) -> Key { + match failure { + LoginFailure::MsaNotConfigured => Key::AccountLoginNotConfigured, + _ => Key::AccountLoginFailed, + } +} diff --git a/crates/app-iced/src/pages/account/tests.rs b/crates/app-iced/src/pages/account/tests.rs index debfc41..92b0651 100644 --- a/crates/app-iced/src/pages/account/tests.rs +++ b/crates/app-iced/src/pages/account/tests.rs @@ -5,6 +5,8 @@ //! | `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::{ @@ -133,6 +135,55 @@ fn a_failure_with_a_provider_code_renders_english_and_the_code() { 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(""), "示例只许用占位,不许写值:{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() { diff --git a/crates/app-iced/src/pages/account/tests_text.rs b/crates/app-iced/src/pages/account/tests_text.rs index a10299b..d5fbd57 100644 --- a/crates/app-iced/src/pages/account/tests_text.rs +++ b/crates/app-iced/src/pages/account/tests_text.rs @@ -16,7 +16,7 @@ use unlucky_i18n::{Key, Lang}; use unlucky_presenter::dto::AccountNoticeCode; -use super::login::failure_body_key; +use super::login::{failure_body_key, failure_title_key}; use super::model::{AuthlibPanel, DevicePanel, Hint, Page}; /// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。 @@ -74,6 +74,7 @@ pub(super) fn on_screen(page: &Page) -> String { } DevicePanel::Browser { link, .. } => out.push_str(link), DevicePanel::Failed { failure } => { + out.push_str(catalog.text(failure_title_key(*failure))); out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); } diff --git a/crates/i18n/src/catalog/account.rs b/crates/i18n/src/catalog/account.rs index a597fa8..c498e62 100644 --- a/crates/i18n/src/catalog/account.rs +++ b/crates/i18n/src/catalog/account.rs @@ -89,6 +89,15 @@ macro_rules! account_entries { AccountFailureNoProfile => ("The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"), AccountFailureStore => ("Could not save the account to disk.", "账户没能写到磁盘上。"), AccountFailureOther => ("The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"), + // M132:缺 client id 是**配置项缺失**,原因就是用户能执行的下一步,所以这一条 + // 必须自己说清「缺什么、怎么补」,不许退回上面那句「完整原因在日志里」。 + // 纪律:只写环境变量名与 `` 占位,**不许出现任何真实 client id**; + // 也不许写成「未支持 / unsupported」——登录链路是完整的。 + AccountLoginNotConfigured => ("Microsoft sign-in is not configured", "微软登录未配置"), + AccountFailureMsaNotConfigured => ( + "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID= before starting the launcher, or build with ULL_MSA_CLIENT_ID=. Then restart the launcher: a running process does not pick up a new environment variable.", + "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=,或构建时注入:ULL_MSA_CLIENT_ID= cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。" + ), AccountDeviceRequesting => ("Contacting Microsoft…", "正在联系微软…"), AccountLoginSucceeded => ("Signed in as {name}", "已登录 {name}"), // ── 账户页横幅(添加/删除/切换的结果;页面上真实可见,不是 toast) @@ -111,3 +120,63 @@ macro_rules! account_entries { } pub(crate) use account_entries; + +#[cfg(test)] +mod tests { + use crate::{Catalog, Key, Lang}; + + /// **M132**:缺 client id 这条是本页唯一「说清原因」的失败文案,它的纪律比别的正文更硬。 + /// + /// 抓得住:文案退回「登录失败 / 完整原因在日志里」、退化成「未支持」、或者只说缺 id 却 + /// 不给要设的变量名;也能抓住有人把某个真实 client id 写进词条。 + /// 抓不住:读起来顺不顺(那靠 review 与截图)。 + #[test] + fn the_missing_client_id_copy_says_what_to_configure_in_both_languages() { + for &lang in Lang::ALL { + let catalog = Catalog::of(lang); + let title = catalog.text(Key::AccountLoginNotConfigured); + let body = catalog.text(Key::AccountFailureMsaNotConfigured); + let all = format!("{title}\n{body}"); + + assert!(body.contains("ULL_MSA_CLIENT_ID"), "{}: {all}", lang.tag()); + assert!( + body.contains(""), + "示例只许用占位:{}: {all}", + lang.tag() + ); + // 面板上是「标题 + 正文」一起:结论(未配置)给标题,怎么做给正文。 + assert!( + all.to_lowercase().contains("not configured") || all.contains("未配置"), + "要说清「未配置」:{}: {all}", + lang.tag() + ); + assert!(!body.to_lowercase().contains("unsupported"), "{all}"); + assert!( + !body.contains("未支持") && !body.contains("未实现"), + "{all}" + ); + // 不许退回「完整原因在日志里」那句(那正是用户报的 bug) + assert!(!body.contains("launcher log"), "{all}"); + assert!(!body.contains("启动器日志"), "{all}"); + // 硬约束:`ULL_MSA_CLIENT_ID=` 后面只许跟 `` 占位——写进来一个真 id 就红。 + assert_eq!( + body.matches("ULL_MSA_CLIENT_ID=").count(), + body.matches("ULL_MSA_CLIENT_ID=").count(), + "示例里出现了真 id:{all}" + ); + } + for &lang in Lang::ALL { + let catalog = Catalog::of(lang); + let title = catalog.text(Key::AccountLoginNotConfigured); + assert!( + title.to_lowercase().contains("not configured") || title.contains("未配置"), + "{}: {title}", + lang.tag() + ); + assert!( + !title.contains('=') && !title.contains("ULL_MSA_CLIENT_ID"), + "标题只报结论,变量名在正文里:{title}" + ); + } + } +} diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 9b24afb..3bd438c 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -1,7 +1,5 @@ -//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。「为什么有这张表、它抓得住 -//! 什么」全在 `catalog.rs` 的 `mod golden;` 文档里——本文件只放数据,是因为每加一条 key 就多一行。 - -/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。 +//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 逐字快照,第一列是 `Key::name()`)。「为什么 +//! 有这张表、它抓得住什么」全在 `catalog.rs` 的 `mod golden;` 里;本文件只放数据。 pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("NavInstances", "Instances", "实例"), ("NavDownload", "Versions", "版本管理"), @@ -381,6 +379,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"), ("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"), ("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"), + ("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"), + ("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID= before starting the launcher, or build with ULL_MSA_CLIENT_ID=. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=,或构建时注入:ULL_MSA_CLIENT_ID= cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"), ("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"), ("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"), ("AccountNoticeAdded", "Added {name}", "已添加 {name}"), diff --git a/crates/presenter/src/dto/accounts/failure.rs b/crates/presenter/src/dto/accounts/failure.rs index 3d60ffe..ac07437 100644 --- a/crates/presenter/src/dto/accounts/failure.rs +++ b/crates/presenter/src/dto/accounts/failure.rs @@ -10,6 +10,16 @@ //! 页面据 `code()` 选 i18n key 出标题 + 正文 + 一行 `Code: …`;原始诊断留在 `message` / //! `detail` 里,**只进日志与详细信息,不上屏**。 //! +//! ## 唯一一个「说清原因」的例外(M132) +//! +//! [`LoginFailure::MsaNotConfigured`]。它上屏的**不是后端诊断串**,而是 catalog 里一条固定 +//! 文案(`Key::AccountFailureMsaNotConfigured` / `Key::AccountLoginNotConfigured`,不含任何 +//! 凭据、token 或内部细节)——「原文不上屏」这条纪律原样保留,页面仍拿不到、也画不出 +//! `message`/`detail`。允许它说清原因的理由只有一条:**原因本身就是用户能执行的下一步** +//! (缺 `ULL_MSA_CLIENT_ID`,设上或构建时注入即可)。判据:没读过代码的用户碰上它,必须能 +//! 从界面自己看出「是缺一个配置」并知道怎么补,而不是去翻日志。其余一切(网络 / 拒绝 / +//! 落盘…)仍旧只给码与分类文案,原文只进日志。 +//! //! 约束:`code()` 一律 ascii 且**不随语言变**(日志、测试、用户报障都靠它对齐)。 use serde::{Deserialize, Serialize}; @@ -37,6 +47,12 @@ pub enum LoginFailure { NoProfile, /// 落盘失败(账户库读写) StoreFailed, + /// 微软轨的 client id 没配(`ULL_MSA_CLIENT_ID` 运行期/构建期两处都没有)——**配置缺失**, + /// 不是登录被拒、也不是「没实现」(M132)。 + /// + /// 单独成码的理由见模块文档「唯一一个『说清原因』的例外」:这一类的原因就是用户能执行的 + /// 下一步,所以它的正文必须说清「要设哪个环境变量」,而不是「完整原因在日志里」。 + MsaNotConfigured, /// 其它(未知原因;原文只在 `message`) Other, } @@ -53,6 +69,7 @@ impl LoginFailure { Self::BadCredentials => "bad_credentials", Self::NoProfile => "no_profile", Self::StoreFailed => "store_failed", + Self::MsaNotConfigured => "msa_not_configured", Self::Other => "other", } } diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 8e3b613..3edcca3 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -9,6 +9,9 @@ //! //! 取消语义:轮询/等回调都跑在 `core` 里、没有取消钩子,所以取消只把面板收回;线程收尾时 //! 若发现「已取消」就不入库(避免用户点了取消却凭空多出一个账户)。 +//! +//! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板, +//! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -16,7 +19,7 @@ use std::sync::Arc; use unlucky_core::auth::account::Credentials; use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession}; use unlucky_core::auth::offline::offline_meta; -use unlucky_core::auth::{AccountStore, AuthResult}; +use unlucky_core::auth::{AccountStore, AuthError, AuthResult}; use unlucky_core::download::blocking::block_on; use crate::dto::CoreEvent; @@ -185,7 +188,7 @@ pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-login", move || { let auth = match microsoft_auth(&env) { Ok(auth) => auth, - Err(message) => return fail(&tx, LoginFailure::Other, message), + Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; let tx_prompt = tx.clone(); let result = block_on(auth.device_code_login(|prompt| { @@ -208,7 +211,7 @@ pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { spawn("msa-authcode", move || { let auth = match microsoft_auth(&env) { Ok(auth) => auth, - Err(message) => return fail(&tx, LoginFailure::Other, message), + Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), }; let tx_prompt = tx.clone(); let result = block_on(auth.authcode_login(|prompt| { @@ -225,14 +228,24 @@ pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { } /// 生产入口的 `MicrosoftAuth`:client id 取环境/构建期,失败日志落 `<数据根>/logs/`。 -fn microsoft_auth(env: &HostEnv) -> Result { - MicrosoftAuth::from_env() - .map(|auth| { - auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( - env.layout.logs(), - ))) - }) - .map_err(|error| error.to_string()) +fn microsoft_auth(env: &HostEnv) -> AuthResult { + MicrosoftAuth::from_env().map(|auth| { + auth.with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( + env.layout.logs(), + ))) + }) +} + +/// `microsoft_auth` 失败 → 原因码。 +/// +/// **只有「两处都没配 client id」单独成码**(M132):`from_env` 的 `Unsupported` 是它唯一的 +/// 产出点,且这条路径零网络。上屏的正文是 catalog 里的固定文案(原文照旧只进日志),理由是 +/// 这一类的原因就是用户的下一步(设 `ULL_MSA_CLIENT_ID`);其余失败仍走 [`classify`]。 +fn auth_failure(error: &AuthError) -> LoginFailure { + match error { + AuthError::Unsupported(_) => LoginFailure::MsaNotConfigured, + other => classify(other), + } } /// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。 @@ -283,100 +296,4 @@ fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result AuthError { + AuthError::Denied { + code: code.to_string(), + description: "bad".to_string(), + } +} + +fn network() -> AuthError { + AuthError::Network { + url: "https://x".to_string(), + detail: "dns".to_string(), + } +} + +#[test] +fn failure_codes_are_classified_from_variants_not_chinese_text() { + assert_eq!(classify(&network()), LoginFailure::Unreachable); + let whitelist = classify(&denied("AADSTS7000218")); + assert_eq!(whitelist, LoginFailure::NotWhitelisted); + assert_eq!(classify(&denied("invalid_grant")), LoginFailure::Denied); + assert_eq!(classify(&AuthError::Canceled), LoginFailure::Other); + assert!(mentions_whitelist("AADSTS7000218")); + assert!(!mentions_whitelist("invalid_grant")); +} + +/// **M132**:只有「缺 client id」变成「需要配置」,网络/凭据失败不许跟着变(否则成噪音)。 +#[test] +fn only_a_missing_client_id_becomes_the_config_gap_code() { + let missing = auth_failure(&AuthError::Unsupported(String::new())); + assert_eq!(missing, LoginFailure::MsaNotConfigured); + assert_eq!(auth_failure(&network()), LoginFailure::Unreachable); + assert_eq!(auth_failure(&denied("invalid_grant")), LoginFailure::Denied); +} + +/// **M132 的入口锚(复审 r1 P2-1)**:缺 client id 时,**两条登录流**回给页面的那条事件里 +/// 必须带 `MsaNotConfigured`。 +/// +/// 抓得住(实测过的变异):把 `start_login` / `start_browser_login` 里的 +/// `auth_failure(&error)` 改回 `LoginFailure::Other`——那是页面退回「完整原因在启动器日志里」 +/// 的唯一开关,而 `auth_failure` 自己的单测对它一声不响。 +/// 零网络:`MicrosoftAuth::from_env` 在 id 判定处就地早退,连 HTTP 客户端都不建,所以这里 +/// 不需要任何桩,也不会因为没网而红。 +/// +/// **它要求一个「两处都没有 client id」的构建**:构建期烘焙了 id(`option_env!`)的构建里它 +/// `panic!` 而不是跳过——跳过就等于让这条锚悄悄失效(r2 复审实测过那种形态),红着喊人才 +/// 逼得人做决定(不带 id 构建,或给 `resolve_client_id` 加接缝后重写这条锚)。 +#[test] +fn a_missing_client_id_reaches_the_login_panel_as_a_config_gap() { + // 环境变量是**进程级**的:动它的用例必须串行(core 侧同一套写法),否则并行跑互相污染。 + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + let _lock = ENV_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::remove_var(CLIENT_ID_ENV); + // panic 也要还原:不还原的话后面所有用例都会在一个「没配 id」的进程里跑。 + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var(CLIENT_ID_ENV, value), + None => std::env::remove_var(CLIENT_ID_ENV), + } + } + } + let _restore = Restore(saved); + + // 环境变量已经摘掉了,此时若仍报告「已配置」,来源只可能是构建期注入 + // (`ULL_MSA_CLIENT_ID= cargo build` ⇒ `option_env!`),或者 `resolve_client_id` 的 + // 实现变了。**这种构建里必须红**:这条锚验证不了缺 id 路径,静默 `return` 就是让锚悄悄失效 + // (r2 复审实测:注入 id + 同一变异 → 全绿 22 passed,正是塔台最怕的「守门空跑」形态)。 + if let Some(source) = client_id_status().source() { + panic!( + "这条锚要求「两处都没有 client id」的构建,但二进制里 client id 已被报告为已配置\ + (source={source:?},环境变量 {CLIENT_ID_ENV} 已摘掉)。要么不带 \ + ULL_MSA_CLIENT_ID 构建,要么给 `unlucky_core::auth::microsoft::resolve_client_id` \ + 加一个可注入的接缝,再按接缝重写这条锚。" + ); + } + + assert_config_gap("device", start_login); + assert_config_gap("authcode", start_browser_login); +} + +/// 起一条登录流、等它回的第一条登录事件,并断言那是一条「缺配置」而不是通用失败。 +/// +/// 缺 id 时这条路径**零网络**(`from_env` 在 id 判定处早退),所以事件立刻到,不需要桩。 +fn assert_config_gap(name: &str, start: fn(HostEnv, JobSender, LoginJob)) { + let dir = std::env::temp_dir().join(format!("ull-msa-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (tx, rx) = super::super::job::channel(); + start(HostEnv::at_root(&dir), tx, LoginJob::new()); + let view = match rx.recv_timeout(Duration::from_secs(10)) { + Ok(HostTask::Login(view)) => view, + Ok(_) => panic!("{name}:缺 id 时第一条事件就该是登录失败"), + Err(error) => panic!("{name}:等不到登录事件({error})"), + }; + assert_eq!(view.failure, Some(LoginFailure::MsaNotConfigured), "{name}"); + // 原文(含要设的变量名)仍然只在 `message` 里:它只进日志,页面画不出来。 + assert!( + view.message.contains(CLIENT_ID_ENV), + "{name}: {}", + view.message + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn offline_name_rule_matches_the_ui_contract() { + assert!(valid_offline_name("Steve")); + assert!(valid_offline_name("a_1")); + assert!(!valid_offline_name("ab"), "太短"); + assert!(!valid_offline_name("中文名字"), "非 ascii"); + assert!(!valid_offline_name("has space")); + assert!(!valid_offline_name("x")); +} + +#[test] +fn a_bad_offline_name_is_classified_not_stringified() { + let dir = std::env::temp_dir().join(format!("ull-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let env = HostEnv::at_root(&dir); + std::fs::create_dir_all(env.layout.auth_data()).expect("auth 目录"); + let failure = add_offline(&env, "ab").expect_err("太短要报错"); + assert_eq!(failure.code, AccountNoticeCode::OfflineNameInvalid); + assert!(!failure.detail.is_empty()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn login_job_cancel_flag_is_shared() { + let job = LoginJob::new(); + let clone = job.clone(); + assert!(!clone.is_cancelled()); + job.cancel(); + assert!(clone.is_cancelled()); +}