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 的「诊断不上屏」约定不动。
This commit is contained in:
AskaEth committed 2026-11-05 20:08:52 +08:00
1 parent 01ca8033be
commit af39efd23b
7 files changed
+223 -52

No files matched your search

+28 -13
View File
@@ -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<Element<'a, PageMessage>> = 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,
}
}
@@ -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("<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() {
@@ -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())]));
}