feat(content): 安装失败的两档分组与「只重试这些」(M191)
M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()), 但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里: - DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开); InstallProgressView 加 retry: Vec<InstallFailureView>(project + 档位 + core 原话), 档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind); ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。 - presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法, host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」—— 调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。 install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。 - 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮; 判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。 - i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。 守门(每条都做过变异验证,见报告): ① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]); ② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红; ③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。 数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。
This commit is contained in:
1 parent
5505ea0f91
commit
5a52f38f35
24 files changed
+1072
-24
No files matched your search
@@ -0,0 +1,149 @@
|
||||
//! 「只重试这些」面板的视图模型(M191)。
|
||||
//!
|
||||
//! ## 它画的是什么
|
||||
//!
|
||||
//! 安装失败之后,界面要说清「哪几项没装上、分别是哪种没装上」:
|
||||
//!
|
||||
//! ```text
|
||||
//! 安装未完成 —— 3 项没装上
|
||||
//! 不兼容所选版本/加载器
|
||||
//! P7dR8mSH
|
||||
//! 取不到(网络 / 项目不存在)
|
||||
//! 1eAoo2KR、AANobbMI
|
||||
//! [只重试这 3 个] [关闭]
|
||||
//! ```
|
||||
//!
|
||||
//! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉)
|
||||
//!
|
||||
//! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰);
|
||||
//! 2. **两档不混**:分组按 [`InstallFailureKind`] 分开,两档各有自己的标题 key;
|
||||
//! 3. **只列名单里的**:条目原样来自 DTO(`presenter` 从 `DependencyPlan::retry_request()`
|
||||
//! 的同一份失败名单产出),页面不自己挑、也不补「顺便重装根」。
|
||||
//!
|
||||
//! ## 为什么这里不拼文案
|
||||
//!
|
||||
//! 分组标题是 [`Key`](i18n),条目是项目键(站点数据,不是文案)。拼一句带量的中文句子
|
||||
//! 会把语序写死——英文与中文的量词位置不同(`Retry just these 3` / `只重试这 3 个`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, InstallStage,
|
||||
LoaderKind,
|
||||
};
|
||||
|
||||
/// 面板上的一个档位分组。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetryGroup {
|
||||
/// 哪一档(顺序固定,见 [`InstallFailureKind::ALL`])
|
||||
pub kind: InstallFailureKind,
|
||||
/// 这一档的标题 key(不硬编码中文)
|
||||
pub title: Key,
|
||||
/// 这一档的项目键(顺序 = 计划里的顺序)
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// 「只重试这些」面板(`None` = 不渲染:没有失败项、或已经全成功)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetryPanel {
|
||||
/// 失败的那次装入的目标实例(重试只对这个实例生效)
|
||||
pub instance: String,
|
||||
/// 标题(已按词表渲染:带条数)
|
||||
pub title: String,
|
||||
/// 主按钮文字(已按词表渲染:`只重试这 N 个`)
|
||||
///
|
||||
/// 存在这里而不是画的时候现拼:`Button::new` 要的是 `&'a str`,而词表渲染出来的是
|
||||
/// `String`——存进模型(与 `Hit` / `VersionRow.line` 同一手法)就不必为了一个标签
|
||||
/// 去改组件库的入参类型。
|
||||
pub action: String,
|
||||
/// 按档分组(空组不出现)
|
||||
pub groups: Vec<RetryGroup>,
|
||||
/// 失败项总数(标题与按钮都用这个数)
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
impl RetryPanel {
|
||||
/// 进度快照 → 面板(**唯一**判据;`None` = 视图里不该出现这块)。
|
||||
///
|
||||
/// 只认 `Failed` 这一态:`Done` 没有可重试的东西,`Cancelled` 是用户自己停的
|
||||
/// (那条路上根本没有失败名单),`Planning`/`Downloading` 还在跑。
|
||||
#[must_use]
|
||||
pub fn of(progress: &InstallProgressView, catalog: Catalog) -> Option<Self> {
|
||||
if progress.stage != InstallStage::Failed || !progress.has_retry() {
|
||||
return None;
|
||||
}
|
||||
let total = progress.retry.len();
|
||||
let count = total.to_string();
|
||||
let groups = InstallFailureKind::ALL
|
||||
.into_iter()
|
||||
.map(|kind| RetryGroup {
|
||||
kind,
|
||||
title: title_of(kind),
|
||||
items: progress
|
||||
.retry_of(kind)
|
||||
.map(|item| item.project.clone())
|
||||
.collect(),
|
||||
})
|
||||
.filter(|group| !group.items.is_empty())
|
||||
.collect();
|
||||
Some(Self {
|
||||
instance: progress.instance.clone(),
|
||||
title: catalog.render(Key::ContentInstallFailedTitle, &[("count", count.as_str())]),
|
||||
action: catalog.render(Key::ContentInstallRetryOnly, &[("count", count.as_str())]),
|
||||
groups,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
/// 两档都在吗(「分组」这件事本身的判据:都出现时界面上是两段,不是一段)。
|
||||
#[must_use]
|
||||
pub fn has_both_tiers(&self) -> bool {
|
||||
self.groups.len() == 2
|
||||
}
|
||||
|
||||
/// 某一档的条目(用例断言用)。
|
||||
#[must_use]
|
||||
pub fn items_of(&self, kind: InstallFailureKind) -> Option<&[String]> {
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|group| group.kind == kind)
|
||||
.map(|group| group.items.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
/// 档位 → 标题 key(**穷尽 match**:合并两档会少一条文案,用例会红)。
|
||||
#[must_use]
|
||||
pub const fn title_of(kind: InstallFailureKind) -> Key {
|
||||
match kind {
|
||||
InstallFailureKind::Incompatible => Key::ContentInstallRetryIncompatible,
|
||||
InstallFailureKind::Unreachable => Key::ContentInstallRetryUnreachable,
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Content {
|
||||
/// 「只重试这些」那一次请求(M191;`None` = 没有可重试的面板 ⇒ 这个动作不存在)。
|
||||
///
|
||||
/// 载荷里 `retry: true` 是**唯一**的语义开关:host 收到它就不解根项目,而是拿上一轮的计划
|
||||
/// 调 `DependencyPlan::retry_request()`(只请求失败的那几个项目)。其余字段照旧填,
|
||||
/// 是因为 `ContentInstallRequest` 的判据仍要能读(`project` / `title` 也用于日志);
|
||||
/// 这些字段在重试路径上**不被 host 读取**——过滤条件与落盘目录都来自那份计划。
|
||||
///
|
||||
/// 实例取**失败那次的**实例,不是页面上此刻选中的那个:用户可能已经换了目标实例。
|
||||
pub fn retry_install_request(&self) -> Option<ContentInstallRequest> {
|
||||
let retry = self.retry.as_ref()?;
|
||||
let hit = self.selected_hit();
|
||||
let target = self
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| target.id == retry.instance);
|
||||
Some(ContentInstallRequest {
|
||||
project: hit.map(|hit| hit.id.clone()).unwrap_or_default(),
|
||||
title: hit.map(|hit| hit.title.clone()).unwrap_or_default(),
|
||||
kind: self.kind,
|
||||
source: hit.map_or(ContentSource::Modrinth, |hit| hit.source),
|
||||
instance: retry.instance.clone(),
|
||||
game_version: target.map_or_else(String::new, |target| target.mc_version.clone()),
|
||||
loader: target.map_or(LoaderKind::Vanilla, |target| target.loader),
|
||||
retry: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -355,3 +355,10 @@ fn loader_names_are_ascii_so_the_english_baseline_stays_english() {
|
||||
"不能画「原版」"
|
||||
);
|
||||
}
|
||||
|
||||
// M191:安装失败的两档分组与「只重试这些」(按主题分卷,父文件已贴 400 行硬线)
|
||||
//
|
||||
// `#[path]` 显式给出:本文件自己是 `#[path = "model/tests.rs"]` 挂上去的,子模块默认按
|
||||
// 「父模块所在目录」找,会指到 `model/retry.rs`(视图模型本体)而不是这里的 `tests/retry.rs`。
|
||||
#[path = "tests/retry.rs"]
|
||||
mod retry;
|
||||
@@ -0,0 +1,129 @@
|
||||
//! M191:安装失败的两档分组 + 「只重试这些」(`super` 按主题分卷,父文件已贴 400 行硬线)。
|
||||
//!
|
||||
//! 三条变异守门:两档不许合并 / 全成功不许渲染 / 重试只针对失败那次的实例。
|
||||
|
||||
use super::super::{Content, PageState, RetryPanel};
|
||||
use super::{hit, results, state_with};
|
||||
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
|
||||
};
|
||||
|
||||
/// 一份「两档都有」的失败进度快照。
|
||||
fn failed_progress() -> unlucky_presenter::dto::InstallProgressView {
|
||||
let mut progress = InstallProgressView {
|
||||
version: "Sodium".to_string(),
|
||||
instance: "survival".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
files_done: 1,
|
||||
files_total: 4,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
progress.retry = vec![
|
||||
InstallFailureView::new(
|
||||
"P7dR8mSH",
|
||||
InstallFailureKind::Incompatible,
|
||||
"没有匹配的版本",
|
||||
),
|
||||
InstallFailureView::new("1eAoo2KR", InstallFailureKind::Unreachable, "HTTP 404"),
|
||||
InstallFailureView::new("AANobbMI", InstallFailureKind::Unreachable, "连不上 CDN"),
|
||||
];
|
||||
progress
|
||||
}
|
||||
|
||||
/// 两档**分组**画,各有自己的标题;条目按档分开,不混成一段。
|
||||
///
|
||||
/// 变异守门②:把两档合并(`kind_of` 都映成 `Unreachable`,或 `title_of` 都返回同一个 key)
|
||||
/// ⇒ `groups.len()` 变 1 / 两个 title 相同 ⇒ 这条红。
|
||||
#[test]
|
||||
fn the_two_tiers_are_two_groups_with_two_labels() {
|
||||
let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板");
|
||||
assert_eq!(panel.total, 3);
|
||||
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
|
||||
assert_eq!(
|
||||
panel.items_of(InstallFailureKind::Incompatible),
|
||||
Some(["P7dR8mSH".to_string()].as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
panel.items_of(InstallFailureKind::Unreachable),
|
||||
Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice())
|
||||
);
|
||||
let titles: Vec<Key> = panel.groups.iter().map(|group| group.title).collect();
|
||||
assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个");
|
||||
// 文案来自词表(不是硬编码中文):英文目录下这两句必须不同且不含中文
|
||||
let incompatible = Lang::En.catalog().text(titles[0]);
|
||||
let unreachable = Lang::En.catalog().text(titles[1]);
|
||||
assert_ne!(incompatible, unreachable);
|
||||
assert!(incompatible.is_ascii(), "{incompatible}");
|
||||
assert!(unreachable.is_ascii(), "{unreachable}");
|
||||
// 按钮文字带条数,也来自词表
|
||||
assert!(panel.action.contains('3'), "{}", panel.action);
|
||||
assert!(panel.action.is_ascii(), "{}", panel.action);
|
||||
}
|
||||
|
||||
/// 只有一档失败时只画一段(空组不出现——空的「取不到」标题会让用户以为那里也坏了)。
|
||||
#[test]
|
||||
fn an_empty_tier_does_not_get_a_group() {
|
||||
let mut progress = InstallProgressView {
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
progress.retry = vec![InstallFailureView::new(
|
||||
"P7dR8mSH",
|
||||
InstallFailureKind::Incompatible,
|
||||
"没有匹配的版本",
|
||||
)];
|
||||
let panel = RetryPanel::of(&progress, Lang::En.catalog()).expect("有失败项就有面板");
|
||||
assert!(!panel.has_both_tiers());
|
||||
assert!(panel.items_of(InstallFailureKind::Unreachable).is_none());
|
||||
}
|
||||
|
||||
/// **全成功时不渲染**:`Done` 且没有失败项 ⇒ 面板不存在(不是置灰,是连构造都没有)。
|
||||
///
|
||||
/// 变异守门③:把 `RetryPanel::of` 的判据去掉(恒返回 `Some`)⇒ 这条红。
|
||||
#[test]
|
||||
fn a_fully_successful_install_has_no_retry_surface_at_all() {
|
||||
let done = InstallProgressView {
|
||||
version: "Sodium".to_string(),
|
||||
instance: "survival".to_string(),
|
||||
stage: InstallStage::Done,
|
||||
files_done: 4,
|
||||
files_total: 4,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
assert!(RetryPanel::of(&done, Lang::En.catalog()).is_none());
|
||||
// 页面上也一样:状态是 Done ⇒ 页底那块既没有重试面板,也没有失败面板
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
state.install = Some(done);
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert!(model.retry.is_none(), "全成功还在页面上画重试面板");
|
||||
assert!(model.panel.is_none());
|
||||
assert!(model.retry_install_request().is_none());
|
||||
|
||||
// 失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西
|
||||
let mut failed = InstallProgressView {
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
failed.retry.clear();
|
||||
assert!(RetryPanel::of(&failed, Lang::En.catalog()).is_none());
|
||||
}
|
||||
|
||||
/// 「只重试这些」发出去的是**失败那次的实例** + `retry: true`(不是页面此刻选中的实例,
|
||||
/// 也不是一次新的根项目安装)。
|
||||
#[test]
|
||||
fn the_retry_action_targets_the_failed_attempt() {
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
// 名单要有,否则这块入口根本不存在
|
||||
state.install = Some(failed_progress());
|
||||
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert!(model.retry.is_some());
|
||||
let request = model.retry_install_request().expect("有面板就该有请求");
|
||||
assert!(
|
||||
request.retry,
|
||||
"重试必须是 retry: true(否则 host 会从根解起)"
|
||||
);
|
||||
assert_eq!(request.instance, "survival");
|
||||
}
|
||||
Reference in new issue
Block a user