r1 复审 p2-1(真回归,我引入的):`loader_badge` 把「非原版 + 空版本号」也送进了
`Key::LoaderVanilla`。而 `host::game::loader_from_id("1.20.1-forge")` 这类 id 取不到版本段
(它按 `forge-` 找,id 却是 `-forge` 结尾),`LoaderInfo::badge()` 对它们给的正是品牌名
`Forge` / `NeoForge` / `OptiFine` —— 于是这三个显示成「原版 / Vanilla」。
判据改成「**加载器类型本身是原版** ⇒ 过词表,其余交给 `badge()`」:
- 修的是判据,不是加分支:函数注释本来就写着「只有原版这一支要过词表」,代码与注释相矛盾。
- 新回归测试 `the_loader_badge_keeps_the_brand_when_the_version_is_missing`:
`1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 三条 id 各断言列表副文案与 LOADER
卡的值都是品牌名(中英两套语言各跑一遍,品牌名两套语言相同)。
变异实测:把判据改回旧写法 ⇒ 立刻红(`left: "1.20.4 · Vanilla" / right: "1.20.4 · Forge"`,
与复审给的实证逐字相同)。
r1 复审 p2-2(不阻塞,按塔台要求顺手做):源码反扫从**黑名单 29 条旧串**翻成**白名单**。
- 先数过:五个页面源码里一共 13 个字面量,其中带 ASCII 单词的只有 6 条,全是专有名词 /
技术标识 / 占位符名 / 族名前缀(`{date} {time} UTC` / `isolated` / `shared` / `pid` /
`reason` / `Instances`),逐条写了理由。
- 判据反过来之后,往页面里**新写**一句英文也会红(复审抓到的哑点)——变异实测:
写进 `"Ready"` ⇒ 红;写回 `"ALL INSTANCES"` ⇒ 红。
- 反向锚保留:扫描器必须真扫到 ≥6 条、`Texts::of(` / `Header::new(texts.title())` /
`Key::Instances` / `Key::InstancesStage` 必须在用(防止断言空转)。
321 lines
12 KiB
Rust
321 lines
12 KiB
Rust
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
|
||
|
||
use super::*;
|
||
use unlucky_i18n::Key;
|
||
use unlucky_presenter::dto::{
|
||
InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||
};
|
||
use unlucky_presenter::mock_data::mock_instances;
|
||
|
||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||
InstanceSummary {
|
||
id: "survival".to_string(),
|
||
name: "Survival".to_string(),
|
||
mc_version: "1.20.4".to_string(),
|
||
loader: LoaderInfo {
|
||
kind: LoaderKind::Fabric,
|
||
version: "0.16.5".to_string(),
|
||
},
|
||
last_played: Some("2026-10-29T19:22:00Z".to_string()),
|
||
runtime,
|
||
icon: None,
|
||
isolation: "isolated".to_string(),
|
||
version_id: "1.20.4".to_string(),
|
||
version_present: true,
|
||
}
|
||
}
|
||
|
||
/// **取证不变量(M100)**:`ForcedState` 的三档在产品路径上必须映射成「页面控件怎么画」。
|
||
///
|
||
/// 抓得住:① 谁把 `Rest` 映射成 Hovered/Pressed(那会让 `--screenshot` 的默认值反而画出状态层,
|
||
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
|
||
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
|
||
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿 / **给定语言**。
|
||
fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang) -> pages::Model {
|
||
let draft = pages::InstallDraft::default();
|
||
let page = pages::content::PageState::default();
|
||
model(
|
||
state,
|
||
1180.0,
|
||
forced,
|
||
pages::Category::All,
|
||
&draft,
|
||
lang,
|
||
&page,
|
||
)
|
||
}
|
||
|
||
/// 同 [`at_lang`],语言取英文(绝大多数断言只关心「快照 → 模型」的映射,不关心译文)。
|
||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||
at_lang(state, forced, Lang::En)
|
||
}
|
||
|
||
#[test]
|
||
fn the_forced_state_maps_to_the_expected_interaction() {
|
||
let state = AppState::new();
|
||
let forced = |forced| at(&state, Some(forced)).forced;
|
||
|
||
assert_eq!(
|
||
forced(crate::cli::ForcedState::Rest),
|
||
Some(Interaction::Enabled),
|
||
"Rest = 静息态(压掉系统光标 hover)"
|
||
);
|
||
assert_eq!(
|
||
forced(crate::cli::ForcedState::Hover),
|
||
Some(Interaction::Hovered)
|
||
);
|
||
assert_eq!(
|
||
forced(crate::cli::ForcedState::Pressed),
|
||
Some(Interaction::Pressed)
|
||
);
|
||
assert_eq!(at(&state, None).forced, None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_snapshot_becomes_the_page_model() {
|
||
let mut state = AppState::new();
|
||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstancesChanged(mock_instances()),
|
||
));
|
||
state.navigate(Route::Detail("survival".to_string()));
|
||
|
||
let model = at(&state, None);
|
||
assert_eq!(model.screen, pages::Screen::Instances);
|
||
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
|
||
assert_eq!(model.window_width, 1180.0);
|
||
assert_eq!(model.instances.rows.len(), 4, "mock 的四个实例");
|
||
assert_eq!(model.instances.rows[0].id, "survival");
|
||
|
||
let detail = model.instances.detail.expect("路由是 Detail,要有详情");
|
||
assert_eq!(detail.id, "survival");
|
||
assert_eq!(detail.meta.len(), 6);
|
||
assert!(detail.can_launch, "空闲实例可启动");
|
||
assert!(!detail.can_stop);
|
||
assert!(detail.note.is_none(), "真快照没有正文段");
|
||
}
|
||
|
||
#[test]
|
||
fn a_busy_instance_offers_stop_instead_of_play() {
|
||
let mut state = AppState::new();
|
||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![instance(
|
||
InstanceRuntime::Busy {
|
||
stage: LaunchStage::Download,
|
||
progress: Progress {
|
||
done: 750,
|
||
total: 1_500,
|
||
},
|
||
},
|
||
)]),
|
||
));
|
||
state.navigate(Route::Detail("survival".to_string()));
|
||
|
||
let model = at(&state, None);
|
||
assert_eq!(
|
||
model.instances.rows[0].status.as_deref(),
|
||
Some("Downloading · 50%")
|
||
);
|
||
let detail = model.instances.detail.expect("有详情");
|
||
assert!(!detail.can_launch, "启动中不许再点 Play(不做乐观回显)");
|
||
assert!(detail.can_stop, "启动中要能 Stop");
|
||
}
|
||
|
||
#[test]
|
||
fn failed_instances_can_be_retried() {
|
||
let failed = instance(InstanceRuntime::Failed {
|
||
reason: "exit 1".to_string(),
|
||
});
|
||
assert_eq!(
|
||
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||
Some("Launch failed · exit 1")
|
||
);
|
||
assert!(
|
||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||
"失败后要能重试"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn timestamps_are_sliced_not_guessed() {
|
||
let en = Lang::En.catalog();
|
||
assert_eq!(
|
||
last_played(Some("2026-10-29T19:22:00Z"), en),
|
||
"2026-10-29 19:22 UTC"
|
||
);
|
||
assert_eq!(last_played(None, en), "Never");
|
||
// 短串 / 非 ASCII 原样返回(不 panic)
|
||
assert_eq!(last_played(Some("2026-10"), en), "2026-10");
|
||
assert_eq!(last_played(Some("昨天"), en), "昨天");
|
||
// 「从未游玩」是唯一走词表的那一支
|
||
assert_eq!(last_played(None, Lang::ZhCn.catalog()), "从未游玩");
|
||
}
|
||
|
||
/// **快照 → 页面文案跟着 `App::lang` 走**(M120 的核心接线断言,模型这一层)。
|
||
///
|
||
/// 抓得住:`view::model` 又把语言写死成 `Lang::En`(M106 之前的原样)、阶段/元信息标签还留着
|
||
/// 英文基线字面量、原版徽标直接画 presenter 的中文「原版」。这三种都不会让任何别的测试红。
|
||
/// 抓不住:页面把 key 画没画上屏(那是 `pages/instances/tests/i18n.rs` 的取样口 + 出图)。
|
||
#[test]
|
||
fn the_runtime_lines_and_meta_labels_follow_the_language() {
|
||
let mut state = AppState::new();
|
||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![
|
||
instance(InstanceRuntime::Running { pid: 1234 }),
|
||
instance(InstanceRuntime::Busy {
|
||
stage: LaunchStage::Verify,
|
||
progress: Progress {
|
||
done: 750,
|
||
total: 1_500,
|
||
},
|
||
}),
|
||
]),
|
||
));
|
||
state.navigate(Route::Detail("survival".to_string()));
|
||
|
||
let en = at_lang(&state, None, Lang::En);
|
||
let zh = at_lang(&state, None, Lang::ZhCn);
|
||
|
||
// 列表 overline:中英各一份,数字与分隔符不动
|
||
assert_eq!(
|
||
en.instances.rows[0].status.as_deref(),
|
||
Some("Running · pid 1234")
|
||
);
|
||
assert_eq!(
|
||
zh.instances.rows[0].status.as_deref(),
|
||
Some("运行中 · pid 1234")
|
||
);
|
||
assert_eq!(
|
||
en.instances.rows[1].status.as_deref(),
|
||
Some("Verifying · 50%")
|
||
);
|
||
assert_eq!(zh.instances.rows[1].status.as_deref(), Some("校验中 · 50%"));
|
||
|
||
// 元信息卡的标签与值
|
||
let (en_detail, zh_detail) = (
|
||
en.instances.detail.expect("有详情"),
|
||
zh.instances.detail.expect("有详情"),
|
||
);
|
||
let labels = |detail: &pages::Detail| -> Vec<String> {
|
||
detail.meta.iter().map(|cell| cell.label.clone()).collect()
|
||
};
|
||
assert_eq!(
|
||
labels(&en_detail),
|
||
vec![
|
||
"VERSION",
|
||
"LOADER",
|
||
"ISOLATION",
|
||
"VERSION ID",
|
||
"FILES",
|
||
"LAST PLAYED"
|
||
]
|
||
);
|
||
assert_eq!(
|
||
labels(&zh_detail),
|
||
vec!["版本", "加载器", "隔离方式", "版本标识", "文件", "上次游玩"]
|
||
);
|
||
|
||
// 值里只有「是否在磁盘上」与「上次游玩」是可翻译的;版本号 / 隔离方式 / 版本标识是数据
|
||
// 隔离档位是 core 的稳定 id(`isolated` / `shared`),也要换成人话;认不出来的原样上屏
|
||
assert_eq!(en_detail.meta[2].value, "Isolated");
|
||
assert_eq!(zh_detail.meta[2].value, "独立目录");
|
||
assert_eq!(en_detail.meta[4].value, "Present on disk");
|
||
assert_eq!(zh_detail.meta[4].value, "已在磁盘上");
|
||
assert_eq!(
|
||
en_detail.meta[5].value, "2026-10-29 19:22 UTC",
|
||
"时间戳是数据:只切片,不随语言变形"
|
||
);
|
||
assert_eq!(zh_detail.meta[5].value, "2026-10-29 19:22 UTC");
|
||
|
||
// 详情状态行与列表 overline 同源
|
||
assert_eq!(zh_detail.status.as_deref(), Some("运行中 · pid 1234"));
|
||
}
|
||
|
||
/// 原版徽标:`LoaderInfo::badge()` 对原版返回**中文**「原版」,英文界面里必须换回 `Vanilla`。
|
||
///
|
||
/// 抓得住:`loader_badge` 退回直接调 `badge()`(M90 ~ M119 的形状)——那样英文界面会漏中文,
|
||
/// 而中文界面又是对的,所以只有这条双语断言抓得住它。
|
||
#[test]
|
||
fn the_vanilla_badge_follows_the_language() {
|
||
let mut bare = instance(InstanceRuntime::Idle);
|
||
bare.loader = LoaderInfo {
|
||
kind: LoaderKind::Vanilla,
|
||
version: String::new(),
|
||
};
|
||
let mut state = AppState::new();
|
||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![bare]),
|
||
));
|
||
state.navigate(Route::Detail("survival".to_string()));
|
||
|
||
let en = at_lang(&state, None, Lang::En);
|
||
let zh = at_lang(&state, None, Lang::ZhCn);
|
||
assert_eq!(en.instances.rows[0].supporting, "1.20.4 · Vanilla");
|
||
assert_eq!(zh.instances.rows[0].supporting, "1.20.4 · 原版");
|
||
}
|
||
|
||
/// **品牌名不许被「版本号为空」吞掉**(M120 r1 复审的 p2-1 回归)。
|
||
///
|
||
/// 背景:`host::game::loader_from_id("1.20.1-forge")` 这类 id 里取不到版本段(它按 `forge-`
|
||
/// 找,而 id 的写法是 `-forge` 结尾),于是 `LoaderInfo { kind: Forge, version: "" }`。
|
||
/// `LoaderInfo::badge()` 对这种输入给的正是品牌名 `Forge`——所以判据只能是「类型是原版」,
|
||
/// 不能是「版本号为空」。
|
||
///
|
||
/// 三个 id 各一条断言(复审点名的三档);`Vanilla` 那一支由
|
||
/// `the_vanilla_badge_follows_the_language` 守,两条合起来把 `loader_badge` 的两侧都钉住。
|
||
///
|
||
/// 抓得住:`loader_badge` 把「非原版 + 空版本号」也送进 `Key::LoaderVanilla`
|
||
/// (r1 的真实形状:`left: "1.20.4 · Vanilla" / right: "1.20.4 · Forge"`);
|
||
/// 抓不住:`loader_from_id` 认不出自家 `-fabric-` / `-quilt-` 口径这个**更老**的 bug
|
||
/// (在 presenter 里,另一条 mission)。
|
||
#[test]
|
||
fn the_loader_badge_keeps_the_brand_when_the_version_is_missing() {
|
||
// (版本 id, 期望徽标) —— id 形状取自 `host::game::loader_from_id` 的三条真实输入
|
||
let cases = [
|
||
("1.20.1-forge", LoaderKind::Forge, "Forge"),
|
||
("1.20.1-neoforge", LoaderKind::NeoForge, "NeoForge"),
|
||
("1.20.1-OptiFine", LoaderKind::OptiFine, "OptiFine"),
|
||
];
|
||
|
||
for (id, kind, brand) in cases {
|
||
let mut one = instance(InstanceRuntime::Idle);
|
||
one.loader = LoaderInfo {
|
||
kind,
|
||
// `loader_from_id` 对这三条 id 取不到版本段 ⇒ 空串
|
||
version: String::new(),
|
||
};
|
||
let mut state = AppState::new();
|
||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![one]),
|
||
));
|
||
state.navigate(Route::Detail("survival".to_string()));
|
||
|
||
// 品牌名两套语言都不翻译 ⇒ 中英逐字相同
|
||
for lang in [Lang::En, Lang::ZhCn] {
|
||
let model = at_lang(&state, None, lang);
|
||
assert_eq!(
|
||
model.instances.rows[0].supporting,
|
||
format!("1.20.4 · {brand}"),
|
||
"{id} 的徽标被显示成了别的东西(新增的判据「版本号为空 ⇒ 原版」就是这条要拦的)"
|
||
);
|
||
let detail = model.instances.detail.expect("有详情");
|
||
assert_eq!(
|
||
detail.meta[1].label,
|
||
lang.catalog().text(Key::InstancesMetaLoader)
|
||
);
|
||
assert_eq!(detail.meta[1].value, brand, "{id} 的 LOADER 卡值");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_agent_route_has_no_page() {
|
||
let mut state = AppState::new();
|
||
state.navigate(Route::Agent);
|
||
let model = at(&state, None);
|
||
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
|
||
assert_eq!(pages::Screen::of(state.route()), None);
|
||
assert!(model.instances.detail.is_none());
|
||
assert_eq!(model.active, None, "AI 页不高亮任何导航项");
|
||
}
|