From af39efd23b4631ee44809e7a70f3401e361084fe Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 18:25:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(presenter,app-iced,i18n):=20M132=20?= =?UTF-8?q?=E7=BC=BA=20MSA=20client=20id=20=E4=B8=8A=E5=B1=8F=E6=88=90?= =?UTF-8?q?=E3=80=8C=E6=9C=AA=E9=85=8D=E7=BD=AE=20+=20=E6=80=8E=E4=B9=88?= =?UTF-8?q?=E8=A1=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺 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=` 或构建时注入,改完重启),golden 同步。 - app-iced:`failure_title_key` 给这一类专属标题;正文按码查表;这一类**不给「重试」** (进程内的环境变量改不了,重试必然是同一个错)。 - 测试:`a_missing_client_id_tells_the_user_what_to_configure` 断言 UI 模型里出现「未配置」 + 变量名 + `` 占位 + `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 的「诊断不上屏」约定不动。 --- crates/app-iced/src/pages/account/login.rs | 41 ++++++--- crates/app-iced/src/pages/account/tests.rs | 51 +++++++++++ .../app-iced/src/pages/account/tests_text.rs | 3 +- crates/i18n/src/catalog/account.rs | 69 +++++++++++++++ crates/i18n/src/catalog/golden.rs | 8 +- crates/presenter/src/dto/accounts/failure.rs | 17 ++++ crates/presenter/src/host/accounts.rs | 86 +++++++++++-------- 7 files changed, 223 insertions(+), 52 deletions(-) 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..0055162 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)。 @@ -320,34 +333,40 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + fn denied(code: &str) -> 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(&AuthError::Network { - url: "https://x".to_string(), - detail: "dns".to_string() - }), - LoginFailure::Unreachable - ); - assert_eq!( - classify(&AuthError::Denied { - code: "AADSTS7000218".to_string(), - description: "request body must contain client_assertion".to_string(), - }), - LoginFailure::NotWhitelisted - ); - assert_eq!( - classify(&AuthError::Denied { - code: "invalid_grant".to_string(), - description: "bad".to_string(), - }), - LoginFailure::Denied - ); + 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); + } + #[test] fn offline_name_rule_matches_the_ui_contract() { assert!(valid_offline_name("Steve")); @@ -360,8 +379,7 @@ mod tests { #[test] fn a_bad_offline_name_is_classified_not_stringified() { - let dir = - std::env::temp_dir().join(format!("ull-host-accounts-name-{}", std::process::id())); + 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 目录"); From f2149048fb7dad1d07d4f974b642826bebc80638 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 19:22:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(presenter):=20M132=20=E6=8A=8A?= =?UTF-8?q?=E3=80=8C=E7=BC=BA=20client=20id=20=E2=86=92=20=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E7=A0=81=E3=80=8D=E9=82=A3=E8=A1=8C=E9=92=89=E8=BF=9B?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88r1=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1 实测:把 `start_login` / `start_browser_login` 里的 `auth_failure(&error)` 改回 `LoginFailure::Other`,编译通过、884 条测试全绿——也就是说「缺 id 会变成 MsaNotConfigured」 这件事此前只有代码可读。这条正是本 mission 要防的回退,补一条能抓它的锚。 新锚 `a_missing_client_id_reaches_the_login_panel_as_a_config_gap` 不只测 `auth_failure`, 而是**真的起两条登录流**(设备码 + 授权码),从 `JobSender` 收到的第一条 `HostTask::Login` 上断言 `failure == MsaNotConfigured`,并断言原文(含 `ULL_MSA_CLIENT_ID`)仍只在 `message` 里、页面画不出来。零网络:`from_env` 在 id 判定处就地早退,事件立刻到,不需要任何桩。 环境变量是进程级的,所以照 core 的做法串行 + panic 也还原;构建期注入过 id 的构建里 (本仓库 CI/本地都不注入)显式断言「只能是这个原因」后返回,不是 ignore/开关。 测试模块从 `accounts.rs` 拆成 `host/accounts/tests.rs`(`#[cfg(test)] mod tests;`,先例 `dto/accounts.rs`、`host/install.rs`):`accounts.rs` 400 → 299 行,拆完两边都远离 400 行硬线。 scope 已由塔台批准扩到 `crates/presenter/src/host/accounts/**`。 变异验证(最终代码上重跑): - 两处 `auth_failure(&error)` → `LoginFailure::Other`:`221 → 220 passed / 1 failed`(只有新锚红); - 还原:222 全绿。 顺手把 fn 指针数组换成一个 `assert_config_gap(name, start)` 辅助函数, `cargo clippy -D warnings` 的 `type_complexity` 不放过它。 --- crates/presenter/src/host/accounts.rs | 103 +---------- crates/presenter/src/host/accounts/tests.rs | 180 ++++++++++++++++++++ 2 files changed, 181 insertions(+), 102 deletions(-) create mode 100644 crates/presenter/src/host/accounts/tests.rs diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 0055162..3edcca3 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -296,105 +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); - } - - #[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()); - } -} +mod tests; diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs new file mode 100644 index 0000000..d8c9be9 --- /dev/null +++ b/crates/presenter/src/host/accounts/tests.rs @@ -0,0 +1,180 @@ +//! 账户 host 的判据:入库/删除/名字规则、失败分类,以及**缺 client id 的上屏链路**(M132)。 +//! +//! ## 为什么这个文件要跟 `accounts.rs` 分开 +//! +//! `accounts.rs` 顶着 400 行硬线,而 M132 的锚必须**真的走一遍登录入口**(`start_login` / +//! `start_browser_login`)才能钉住「入口 → 原因码」那一行——只测 `auth_failure` 的话,把调用点 +//! 改回 `LoginFailure::Other` 仍然全绿(复审 r1 P2-1 实测)。测试拆出来是为了留出这段空间。 +//! +//! 拆法照抄仓库既有先例(`dto/accounts.rs`、`host/install.rs` 等):父文件里留 +//! `#[cfg(test)] mod tests;`,本文件 `use super::*;` 照旧拿到父模块的一切。 + +use std::time::Duration; + +use unlucky_core::auth::microsoft::{client_id_status, CLIENT_ID_BUILD, CLIENT_ID_ENV}; +use unlucky_core::auth::AuthError; + +use super::*; + +use super::failure::mentions_whitelist; + +#[test] +fn time_display_matches_known_timestamps() { + assert_eq!(display_ms(0), ""); + // 1_769_000_000_000 ms = 2026-01-21T12:53:20Z(取整到分钟) + assert_eq!(display_ms(1_769_000_000_000), "2026-01-21 12:53"); +} + +#[test] +fn offline_add_list_remove_round_trips() { + let dir = std::env::temp_dir().join(format!("ull-host-accounts-{}", 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 name = add_offline(&env, "Steve").expect("加账户"); + assert_eq!(name, "Steve"); + let list = accounts(&env, None); + assert_eq!(list.len(), 1); + assert_eq!(list[0].kind, AccountKind::Offline); + assert!(!list[0].active); + + let active = accounts(&env, Some(list[0].id.as_str())); + assert!(active[0].active); + + let removed = remove(&env, "Steve").expect("删账户"); + assert_eq!(removed, "Steve"); + assert!(accounts(&env, None).is_empty()); + assert!(add_offline(&env, " ").is_err()); + std::fs::remove_dir_all(&dir).ok(); +} + +fn denied(code: &str) -> 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 客户端都不建,所以这里 +/// 不需要任何桩,也不会因为没网而红。 +#[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); + + if client_id_status().is_configured() { + // 构建期注入过 id(`ULL_MSA_CLIENT_ID= cargo build`)的构建里没有「缺配置」可言; + // 本仓库 CI 与本地都不注入,走到这里只允许是这个原因(不是「跳过」开关)。 + assert!( + CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), + "没配环境变量却报告已配置" + ); + return; + } + + 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()); +} From 119b49cf83daa36eae0d174c49ca961f9ebcbdf7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 20:06:55 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(presenter):=20M132=20=E7=83=98?= =?UTF-8?q?=E7=84=99=E4=BA=86=20client=20id=20=E7=9A=84=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=87=8C=E8=AE=A9=E5=85=A5=E5=8F=A3=E9=94=9A=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=BA=A2=EF=BC=88r2=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 复审实测:`ULL_MSA_CLIENT_ID=fake-0000-test-id` 构建 + 同一处变异 → `ok. 22 passed`。 原因是我上一条写成了 `if is_configured() { assert!(CLIENT_ID_BUILD.is_some_and(..)); return; }` ——`remove_var` 之后 `is_configured()` 等价于 `CLIENT_ID_BUILD` 非空,随后的 `assert!` 判的是 同一条件的反面,恒真、无失败路径,于是锚在那种构建里**绿着失效**。这正是本 mission 要防的 「守门空跑」形态,而我报告里还把它写成了「只允许是这个原因」,与代码不符。 改成 `if let Some(source) = client_id_status().source() { panic!(..) }`:环境变量摘掉后仍 报告已配置,就带着 `source=` 和两条出路(不带 id 构建 / 给 `resolve_client_id` 加接缝)直接 红,逼人做决定。没有 `#[ignore]`、没有开关;正常的「两处都没有 id」构建照旧跑两条真登录流。 验证(贴原生输出): - 注入 id + 变异 → FAILED(panic,`source=Build`),旧代码此处是 `ok. 22 passed`; - 注入 id + 无变异 → 也 FAILED(这才是重点:不是靠变异才红,而是拒绝空跑); - 无 id + 变异 → `220 passed; 1 failed`,红的正是这条锚; - 无 id 正常 → `221 passed; 1 ignored`。 --- crates/presenter/src/host/accounts/tests.rs | 23 ++++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index d8c9be9..cce5d36 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -11,7 +11,7 @@ use std::time::Duration; -use unlucky_core::auth::microsoft::{client_id_status, CLIENT_ID_BUILD, CLIENT_ID_ENV}; +use unlucky_core::auth::microsoft::{client_id_status, CLIENT_ID_ENV}; use unlucky_core::auth::AuthError; use super::*; @@ -91,6 +91,10 @@ fn only_a_missing_client_id_becomes_the_config_gap_code() { /// 的唯一开关,而 `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 侧同一套写法),否则并行跑互相污染。 @@ -111,14 +115,17 @@ fn a_missing_client_id_reaches_the_login_panel_as_a_config_gap() { } let _restore = Restore(saved); - if client_id_status().is_configured() { - // 构建期注入过 id(`ULL_MSA_CLIENT_ID= cargo build`)的构建里没有「缺配置」可言; - // 本仓库 CI 与本地都不注入,走到这里只允许是这个原因(不是「跳过」开关)。 - assert!( - CLIENT_ID_BUILD.is_some_and(|value| !value.trim().is_empty()), - "没配环境变量却报告已配置" + // 环境变量已经摘掉了,此时若仍报告「已配置」,来源只可能是构建期注入 + // (`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` \ + 加一个可注入的接缝,再按接缝重写这条锚。" ); - return; } assert_config_gap("device", start_login);