feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)
- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验) - 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、 本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃) - 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条) - 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证) - presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、 皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml) - i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
This commit is contained in:
1 parent
e7b04c4afe
commit
1ff00bbe50
43 files changed
+4343
-117
No files matched your search
@@ -0,0 +1,322 @@
|
||||
//! 账户页视图模型的判据(M114 的核心断言都在这里)。
|
||||
//!
|
||||
//! | 用例 | 抓得住什么 |
|
||||
//! |---|---|
|
||||
//! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 |
|
||||
//! | `an_empty_account_list_offers_all_three_tracks` | 空态变成白页(没有入口) |
|
||||
//! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」(倒计时/过期不可见) |
|
||||
//! | `a_third_party_account_falls_back_to_the_monogram` | 没有皮肤却画白块 / 拿假皮肤充数 |
|
||||
//! | `the_offline_track_keeps_the_agreed_wording` | 离线档案的说明被改写成「兼容旧启动器」那套 |
|
||||
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
AccountKind, AccountView, AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginPhase,
|
||||
DeviceLoginView, SkinImage,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::avatar::Cache;
|
||||
use super::model::{AuthlibPanel, DevicePanel, Form, Page, Panel, Track};
|
||||
|
||||
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 {
|
||||
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
|
||||
}
|
||||
|
||||
/// 把视图模型里**所有会上屏的文本**拼起来(凭据红线就是在这坨里查)。
|
||||
fn visible_text(page: &Page) -> String {
|
||||
let mut out = String::new();
|
||||
for row in &page.rows {
|
||||
out.push_str(&row.name);
|
||||
out.push_str(&row.monogram);
|
||||
out.push_str(&row.kind_label);
|
||||
out.push_str(&row.added);
|
||||
out.push_str(&row.detail);
|
||||
}
|
||||
out.push_str(&page.form.server);
|
||||
out.push_str(&page.form.login);
|
||||
out.push_str(&page.form.offline_name);
|
||||
out.push_str(&page.checked_server);
|
||||
match &page.device {
|
||||
DevicePanel::Hidden => {}
|
||||
DevicePanel::Requesting { message } => out.push_str(message),
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
link,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
out.push_str(code);
|
||||
out.push_str(link);
|
||||
out.push_str(message);
|
||||
}
|
||||
DevicePanel::Browser { link, message, .. } => {
|
||||
out.push_str(link);
|
||||
out.push_str(message);
|
||||
}
|
||||
DevicePanel::Failed { message } => out.push_str(message),
|
||||
DevicePanel::Succeeded { name } => out.push_str(name),
|
||||
}
|
||||
match &page.authlib {
|
||||
AuthlibPanel::Idle => {}
|
||||
AuthlibPanel::Running { message } => out.push_str(message),
|
||||
AuthlibPanel::Ready {
|
||||
server_name,
|
||||
message,
|
||||
} => {
|
||||
out.push_str(server_name);
|
||||
out.push_str(message);
|
||||
}
|
||||
AuthlibPanel::Failed { message } => out.push_str(message),
|
||||
AuthlibPanel::Succeeded { name } => out.push_str(name),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const JWT: &str =
|
||||
"eyJhbGciOiJSUzI1NiIsIng1dCI6IkFCQyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVl";
|
||||
|
||||
/// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 视图模型里不许出现。
|
||||
///
|
||||
/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`),
|
||||
/// 或页面把 DTO 的错误原文直接搬上屏。
|
||||
/// 抓不住:某个将来新增的**独立**字段绕过 `message` 通道(那需要另加断言)。
|
||||
#[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:模拟「将来某个新构造器忘了脱敏」/「后端把原文塞进来」。
|
||||
// 用 `DeviceLoginView::failed(..)` 构造是测不到状态层脱敏的(构造器自己就脱过了)。
|
||||
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
|
||||
DeviceLoginView {
|
||||
phase: DeviceLoginPhase::Failed,
|
||||
message: format!("微软返回错误:accessToken={JWT} refresh_token={JWT}"),
|
||||
..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}"),
|
||||
account_name: String::new(),
|
||||
},
|
||||
)));
|
||||
|
||||
let page = build(&state, &Form::new());
|
||||
let text = visible_text(&page);
|
||||
assert!(!text.contains("eyJ"), "视图模型里出现了 JWT:{text}");
|
||||
assert!(!text.to_lowercase().contains("refreshtoken"), "{text}");
|
||||
assert!(
|
||||
text.contains("⟪redacted⟫"),
|
||||
"错误文案要被标成已脱敏:{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());
|
||||
assert_eq!(Track::ALL.len(), 3);
|
||||
// 三条轨道的文案 key 各不相同(不是三张一样的卡)
|
||||
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);
|
||||
}
|
||||
|
||||
/// 设备码:**倒计时可见**,过期后是「已过期」而不是继续转圈。
|
||||
#[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!(remaining.is_some_and(|secs| secs > 0));
|
||||
assert!(!expired);
|
||||
}
|
||||
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
|
||||
}
|
||||
|
||||
// 起点往前挪 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(), "皮肤预览区没有图就不画");
|
||||
|
||||
// 塞一张坏图:解码失败 → 仍然回落到首字母块(不 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());
|
||||
|
||||
// 离线档案永远没有皮肤:即使被塞了假数据,UI 侧也只认 DTO 的 `kind`
|
||||
state.accounts[1].skin = None;
|
||||
state.accounts[1].kind = AccountKind::Offline;
|
||||
let page = build(&state, &Form::new());
|
||||
assert!(page.rows[1].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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三条轨道在列表里**文案互不相同**(每一行都贴自己的轨道徽标)。
|
||||
#[test]
|
||||
fn every_track_has_its_own_badge() {
|
||||
let mut state = AppState::new();
|
||||
state.accounts = vec![
|
||||
account("a", AccountKind::Microsoft, true),
|
||||
account("b", AccountKind::Authlib, false),
|
||||
account("c", AccountKind::Offline, false),
|
||||
];
|
||||
let page = build(&state, &Form::new());
|
||||
let labels: Vec<&str> = page
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.kind_label.as_str())
|
||||
.collect();
|
||||
assert_eq!(labels.len(), 3);
|
||||
assert_ne!(labels[0], labels[1]);
|
||||
assert_ne!(labels[1], labels[2]);
|
||||
assert!(
|
||||
labels.iter().all(|label| !label.is_empty()),
|
||||
"轨道徽标不能空"
|
||||
);
|
||||
}
|
||||
|
||||
/// 表单状态:三条轨道各开各的,密码关面板即清。
|
||||
#[test]
|
||||
fn the_form_tracks_are_independent() {
|
||||
let mut form = Form::new();
|
||||
form.open(Track::Offline);
|
||||
assert_eq!(form.panel, Panel::Offline);
|
||||
assert!(!form.offline_ready(), "空名字不能提交");
|
||||
form.offline_name = "Alex".to_string();
|
||||
assert!(form.offline_ready());
|
||||
|
||||
form.open(Track::Authlib);
|
||||
assert_eq!(form.panel, Panel::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()
|
||||
},
|
||||
)));
|
||||
assert!(matches!(
|
||||
build(&state, &Form::new()).device,
|
||||
DevicePanel::Requesting { .. }
|
||||
));
|
||||
|
||||
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
|
||||
));
|
||||
}
|
||||
Reference in new issue
Block a user