Merge branch 'feat/m192-retryui-surface-dependency-failures'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 05:06:09 +08:00
commit 3116f43c9f
28 files changed
+1791 -55

No files matched your search

+11
View File
@@ -104,6 +104,15 @@ impl App {
self.dispatch(AppCommand::InstallContent(Box::new(request))); self.dispatch(AppCommand::InstallContent(Box::new(request)));
} }
} }
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
pages::content::Message::RetryFailed => {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
if let Some(request) = model.retry_install_request() {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
// M113: import/export —— 导入/导出区(细节在 `on_pack`) // M113: import/export —— 导入/导出区(细节在 `on_pack`)
pages::content::Message::Pack(message) => self.on_pack(message), pages::content::Message::Pack(message) => self.on_pack(message),
} }
@@ -222,6 +231,8 @@ impl App {
instance: target.id.clone(), instance: target.id.clone(),
game_version: target.mc_version.clone(), game_version: target.mc_version.clone(),
loader: target.loader, loader: target.loader,
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
retry: false,
}) })
} }
} }
+14 -1
View File
@@ -47,6 +47,16 @@
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 | //! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 |
//! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 | //! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 |
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 | //! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
//!
//! ## 安装失败之后:两档分组 + 「只重试这些」(M191)
//!
//! 装入失败且**有可重试项**时,页底那块失败面板换成 [`parts::retry_panel`]:按
//! `InstallFailureKind` 分两段(不兼容 / 取不到,各有自己的词表文案),并给一个「只重试这 N 个」
//! 的实心按钮。按钮发 [`Message::RetryFailed`],app 层翻成 `AppCommand::InstallContent{retry: true}`,
//! host 拿上一轮的计划调 `DependencyPlan::retry_request()` ⇒ **只请求失败的那几个项目**。
//!
//! 判据只有一条且在模型里(`RetryPanel::of`):`stage == Failed && !retry.is_empty()`。
//! 全成功、没有失败项、还在跑,三种情况都得到 `None`——**不渲染**,不是画一个灰按钮。
mod demo; mod demo;
mod layout; mod layout;
@@ -61,7 +71,10 @@ mod view;
mod tests; mod tests;
pub use message::Message; pub use message::Message;
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target}; pub use model::{
loader_name, Blocker, Content, ContentQueryView, Hit, PageState, RetryGroup, RetryPanel,
Status, Target,
};
// M113: import/export // M113: import/export
pub use packui::{request_export, Pack, PackMessage, PackPageState}; pub use packui::{request_export, Pack, PackMessage, PackPageState};
+80 -2
View File
@@ -11,7 +11,7 @@
//! | 变量 | 取值 | 作用 | //! | 变量 | 取值 | 作用 |
//! |---|---|---| //! |---|---|---|
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) | //! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) | //! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` / `retryfail` / `retryok` | 选状态(默认 `ok`;`offline` = M162 的未联网态;`retryfail` = M191 的「有失败项」,`retryok` = M191 的「全部成功」) |
//! //!
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。 //! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
@@ -19,10 +19,52 @@ use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
use super::model::{ use super::model::{
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow, blockers_of, Content, ContentQueryView, Hit, HitFacts, RetryPanel, Status, Target, VersionRow,
}; };
use crate::pages::download::Download; use crate::pages::download::Download;
/// M191「有失败项」那一屏:两档各几个项目,面板按档分组。
///
/// **走真判据**([`RetryPanel::of`]),不手搓面板字段:出图与产品路径读同一个函数,
/// 判据一改这条演示就跟着变(否则演示图能一直画着一个已经不存在的面板)。
fn retry_failure(instance: &str, catalog: Catalog) -> Option<RetryPanel> {
use unlucky_presenter::dto::{
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
};
let mut progress = InstallProgressView {
version: "Sodium".to_string(),
instance: instance.to_string(),
loader: "Fabric".to_string(),
stage: InstallStage::Failed,
files_done: 1,
files_total: 4,
..InstallProgressView::new()
};
// 三种可显示性各一条,与 host 侧 `plan::label_of` 的三种情况同形:
// 根项目 = 请求里的标题、下载失败 = 文件名、不兼容档 = 失败路径上现取的项目名。
progress.retry = vec![
InstallFailureView::new(
"AANobbMI",
"Sodium",
InstallFailureKind::Unreachable,
"下载 sodium-fabric-0.6.0.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
),
InstallFailureView::new(
"P7dR8mSH",
"Entity Culling",
InstallFailureKind::Incompatible,
"没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本",
),
InstallFailureView::new(
"1eAoo2KR",
"fabric-api-0.92.2+1.20.1.jar",
InstallFailureKind::Unreachable,
"下载 fabric-api-0.92.2+1.20.1.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
),
];
RetryPanel::of(&progress, catalog)
}
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 /// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
fn targets() -> Vec<Target> { fn targets() -> Vec<Target> {
vec![ vec![
@@ -254,6 +296,12 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
} else { } else {
None None
}, },
// M191:`retryfail` 画「有失败项」,`retryok` 画「全部成功」——后者走同一条判据,
// 得到 `None`(连按钮都不渲染)。
retry: match state.as_str() {
"retryfail" => retry_failure("survival", catalog),
_ => None,
},
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()], versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
available_versions: demo_versions, available_versions: demo_versions,
versions_note: None, versions_note: None,
@@ -265,3 +313,33 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
pack, pack,
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// 「有失败项」那一屏的两档走**真判据**(`RetryPanel::of`),不是手搓的面板字段。
#[test]
fn the_failure_screen_shows_both_tiers() {
let panel = retry_failure("survival", Lang::En.catalog()).expect("演示必须有面板");
assert_eq!(panel.total, 3);
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
assert_eq!(
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable),
Some(
[
// 根项目:名字来自请求标题
"Sodium (AANobbMI)".to_string(),
// 下载失败:名字来自文件名
"fabric-api-0.92.2+1.20.1.jar (1eAoo2KR)".to_string(),
]
.as_slice()
)
);
// 不兼容档:失败路径上现取的项目名(不是 id)
assert_eq!(
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Incompatible),
Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice())
);
}
}
@@ -37,6 +37,11 @@ pub enum Message {
Retry, Retry,
/// 装进目标实例 /// 装进目标实例
Install, Install,
/// **只重试上次失败的那几项**(M191;不是「整单重装」)
///
/// app 层的落点:`AppCommand::InstallContent` 带 `retry: true`——host 拿上一轮的依赖计划
/// 调 `DependencyPlan::retry_request()`,只重解失败项。
RetryFailed,
/// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`) /// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`)
Pack(super::packui::PackMessage), Pack(super::packui::PackMessage),
} }
@@ -32,10 +32,12 @@ use unlucky_i18n::{Catalog, Key};
use crate::pages::download::Download; use crate::pages::download::Download;
mod items; mod items;
mod retry;
pub(crate) use items::HitFacts; pub(crate) use items::HitFacts;
pub(crate) use items::{capitalize, thousands}; pub(crate) use items::{capitalize, thousands};
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow}; pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
pub use retry::{RetryGroup, RetryPanel};
/// 内容库页要画的东西。 /// 内容库页要画的东西。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -69,6 +71,11 @@ pub struct Content {
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树 /// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。 /// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
pub panel: Option<Download>, pub panel: Option<Download>,
/// 「只重试这些」面板(M191):`None` = 不渲染(全成功 / 没有失败项 / 还在跑)。
///
/// 与 `panel` 互斥地画:失败且有可重试项时,这块**顶替**下载页那块通用失败面板,
/// 一屏只有一个失败块(两个会说同一件事,还各自给一个「关闭」)。
pub retry: Option<RetryPanel>,
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序) /// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
pub versions: Vec<String>, pub versions: Vec<String>,
/// 关键词草稿 /// 关键词草稿
@@ -187,6 +194,11 @@ impl Content {
target_label, target_label,
blockers, blockers,
panel: content_install(state, catalog), panel: content_install(state, catalog),
// M191:失败且有可重试项时这块才存在(全成功 ⇒ `None` ⇒ 视图里连按钮都没有)
retry: state
.install
.as_ref()
.and_then(|progress| RetryPanel::of(progress, catalog)),
versions, versions,
draft: page.draft.clone(), draft: page.draft.clone(),
pack, pack,
@@ -0,0 +1,153 @@
//! 「只重试这些」面板的视图模型(M191)。
//!
//! ## 它画的是什么
//!
//! 安装失败之后,界面要说清「哪几项没装上、分别是哪种没装上」:
//!
//! ```text
//! 安装未完成 —— 3 项没装上
//! 不兼容所选版本/加载器
//! P7dR8mSH ← 拿不到名字时只画 id(合理兜底)
//! 取不到(网络 / 项目不存在)
//! Sodium (AANobbMI) ← 根项目:名字来自请求标题
//! fabric-api-0.92.2.jar (1eAoo2KR) ← 下载失败:名字来自文件名
//! [只重试这 3 个] [关闭]
//! ```
//!
//! 每行是 `InstallFailureView::line()` 的结果(名字优先、id 次要)。名字是**语言中立**的站点
//! 标题或文件名,不是 core 的中文散文——那条线(i18n R2)踩了就回不去。
//!
//! ## 三条判据(都落在 [`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, InstallFailureView,
InstallProgressView, InstallStage, LoaderKind,
};
/// 面板上的一个档位分组。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryGroup {
/// 哪一档(顺序固定,见 [`InstallFailureKind::ALL`])
pub kind: InstallFailureKind,
/// 这一档的标题 key(不硬编码中文)
pub title: Key,
/// 这一档要画的每一行(`名字 (id)`;拿不到名字时就只有 id,顺序 = 计划里的顺序)
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(InstallFailureView::line)
.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,158 @@
//! 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()
};
// 三种可显示性各一条(与 `plan::label_of` 的三种情况同形):
// 根项目用请求里的标题、下载失败用文件名、拿不到名的只画 id。
progress.retry = vec![
InstallFailureView::new(
"AANobbMI",
"Sodium",
InstallFailureKind::Unreachable,
"连不上 CDN",
),
InstallFailureView::new(
"P7dR8mSH",
"Entity Culling",
InstallFailureKind::Incompatible,
"没有匹配的版本",
),
InstallFailureView::new("1eAoo2KR", "", InstallFailureKind::Unreachable, "HTTP 404"),
];
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);
// **P2-1 的守门**:有名字的画「名字 (id)」,没有名字的才只画 id
assert_eq!(
panel.items_of(InstallFailureKind::Incompatible),
Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice())
);
assert_eq!(
panel.items_of(InstallFailureKind::Unreachable),
Some(["Sodium (AANobbMI)".to_string(), "1eAoo2KR".to_string()].as_slice())
);
// 变异守门:把标签置空 ⇒ 这一行退回裸 id,上面两条立刻红
assert!(
panel
.items_of(InstallFailureKind::Unreachable)
.is_some_and(|items| items[0].starts_with("Sodium")),
"根项目必须显示请求里的标题,不是 id"
);
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",
"Entity Culling",
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());
// 判据是**两半**:`Failed` **且** 名单非空。第一半单独钉住——就算快照里残留了名单
// (不该发生,host 成功时会清),`Done` 也不画(r1 复审指出:只去掉这一半曾全绿)。
let mut stale = InstallProgressView {
stage: InstallStage::Done,
..InstallProgressView::new()
};
stale.retry = failed_progress().retry;
assert!(
RetryPanel::of(&stale, Lang::En.catalog()).is_none(),
"Done 态不许画重试面板(判据是「失败且有名单」,不是「有名单」)"
);
// 第二半:失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西
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");
}
@@ -100,6 +100,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
eta_secs: 0, eta_secs: 0,
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。 // 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
failure_kind: None, failure_kind: None,
// 导入没有「只重试这些」这条路径(重试整个导入由导入区自己管)。
retry: Vec::new(),
message: progress.current.clone().unwrap_or_default(), message: progress.current.clone().unwrap_or_default(),
} }
} }
@@ -30,9 +30,11 @@ use super::model::{Content, Hit};
mod detail; mod detail;
mod filters; mod filters;
mod retry;
pub(crate) use detail::detail; pub(crate) use detail::detail;
pub(crate) use filters::{filters, labelled}; pub(crate) use filters::{filters, labelled};
pub(crate) use retry::maybe as retry_panel;
use super::Message as ContentMessage; use super::Message as ContentMessage;
use crate::pages::frame; use crate::pages::frame;
@@ -0,0 +1,140 @@
//! 安装失败后的「只重试这些」面板(M191)。
//!
//! ## 为什么这块画在内容库,而不是复用下载页那块失败面板
//!
//! 下载页的失败面板讲的是「停在哪一步 + 打开日志」,而这里要讲的是**哪几项没装上、属于哪种
//! 没装上、以及能不能只重试它们**。两件事混在一个块里会得到「失败 + 一堆条目 + 两个关闭」。
//! 所以:有可重试名单时([`RetryPanel::of`] 给了 `Some`)这块**顶替**通用失败面板,
//! 一屏只有一个失败块。
//!
//! ## 三个控件行为
//!
//! | 控件 | 消息 | 落点 |
//! |---|---|---|
//! | 只重试这 N 个 | [`ContentMessage::RetryFailed`] | `AppCommand::InstallContent{retry: true}`(host 只重解失败项) |
//! | 关闭 | [`PageMessage::CancelInstall`] | 清 install 快照(对没在跑的安装是安全 no-op,与下载页同一个出口) |
//!
//! 两个按钮都**按不动就不画**:没有名单时整块不存在(不是画一个灰按钮)。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::theme::Ui;
use super::super::model::{Content, RetryPanel};
use super::intent;
use crate::pages::content::Message as ContentMessage;
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 整块面板(`model.retry` 为 `None` 时调用方不该调它——判据在模型里,不在这里)。
pub(crate) fn retry_panel<'a>(
ui: &'a Ui,
catalog: Catalog,
panel: &'a RetryPanel,
) -> Element<'a, PageMessage> {
let head = row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
panel.title.as_str(),
ui.color(Role::Error),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
let mut body = column![head]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 两档分成两段:**各自的标题 + 各自的条目**(合并成一段就说不出「换版本还是检查网络」)。
// 条目之间用 ASCII 逗号:项目键是站点数据,句读不是文案,不该在这里挑中文的「、」。
for group in &panel.groups {
let items = group.items.join(", ");
body = body.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(group.title),
ui.color(Role::OnErrorContainer),
),
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
items,
ui.color(Role::OnErrorContainer),
),
]
.spacing(ui.space(Space::X1)),
);
}
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentInstallRetryHint),
ui.color(Role::OnErrorContainer),
));
let actions = row![
// 主按钮:只重试这些(这个块存在的**唯一**理由)
buttons::filled(
ui,
Button::new(panel.action.as_str(), intent(ContentMessage::RetryFailed))
.leading(IconKind::Refresh),
),
buttons::text(
ui,
// 复用下载页那条现有的「关闭」:同一句话不另立一条要同步的 key(r1 复审 N2)
Button::new(
catalog.text(Key::DownloadDismiss),
PageMessage::CancelInstall
),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
body = body.push(actions);
container(body)
.width(Length::Fill)
.padding(ui.space(Space::X4))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 整页底部那块(判据在 [`Content::retry`];`None` ⇒ 一帧都不画)。
pub(crate) fn maybe<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
model
.retry
.as_ref()
.map(|panel| retry_panel(ui, catalog, panel))
}
@@ -85,3 +85,16 @@ fn the_demo_selection_is_always_one_of_the_hits() {
); );
} }
} }
/// M191:「全部成功」那一屏(默认态)**没有**重试面板——出图与判据同源,不是手搓的。
#[test]
fn the_default_demo_screen_has_no_retry_surface() {
for kind in ContentKind::ALL {
let model = demo(kind, Lang::En);
assert!(
model.retry.is_none(),
"{} 默认态不该有重试面板",
kind.slug()
);
}
}
+6 -1
View File
@@ -98,7 +98,12 @@ fn body<'a>(
.spacing(ui.space(Space::X3)) .spacing(ui.space(Space::X3))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill); .height(Length::Fill);
if let Some(panel) = &model.panel { // M191:有可重试的失败项时,这块**顶替**下载页那块通用失败面板(一屏只有一个失败块);
// 没有名单时它连构造都不存在(`None`),不是画一个灰按钮。
let retry = parts::retry_panel(ui, catalog, model);
if let Some(panel) = retry {
stack = stack.push(divider::full(ui)).push(panel);
} else if let Some(panel) = &model.panel {
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。 // **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
stack = stack stack = stack
.push(divider::full(ui)) .push(divider::full(ui))
@@ -297,6 +297,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
message: String::new(), message: String::new(),
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。 // 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
failure_kind: None, failure_kind: None,
// 同理:没有失败名单 ⇒ 内容库那块「只重试这些」不出现(下载页也不画它)。
retry: Vec::new(),
}, },
catalog, catalog,
) )
+23
View File
@@ -95,6 +95,29 @@ macro_rules! content_entries {
"Installing from CurseForge is not available yet", "Installing from CurseForge is not available yet",
"CurseForge 来源的安装尚未开放" "CurseForge 来源的安装尚未开放"
), ),
// --- M191:安装失败的两档分组与「只重试这些」---
// 两档**各一条**:不兼容要换版本/加载器,取不到要检查网络,合并成一句会让用户
// 对着一条重试必然失败的项反复点重试。
ContentInstallFailedTitle => (
"Install did not finish — {count} items failed",
"安装未完成 —— {count} 项没装上"
),
ContentInstallRetryIncompatible => (
"Not available for this version/loader",
"不兼容所选版本/加载器"
),
ContentInstallRetryUnreachable => (
"Could not fetch (network or missing project)",
"取不到(网络 / 项目不存在)"
),
ContentInstallRetryOnly => (
"Retry just these {count}",
"只重试这 {count} 个"
),
ContentInstallRetryHint => (
"Retry asks again only for the items above; files that already landed stay put.",
"重试只重新解析上面这几项,已落盘的文件不动。"
),
// --- M113: pack import / export(PACKUI)--- // --- M113: pack import / export(PACKUI)---
PackTitle => ("Modpack import & export", "整合包导入 / 导出"), PackTitle => ("Modpack import & export", "整合包导入 / 导出"),
PackImportTitle => ("Import a modpack", "导入整合包"), PackImportTitle => ("Import a modpack", "导入整合包"),
+5
View File
@@ -386,6 +386,11 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"), ("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"),
("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"),
("ContentInstallFailedTitle", "Install did not finish — {count} items failed", "安装未完成 —— {count} 项没装上"),
("ContentInstallRetryIncompatible", "Not available for this version/loader", "不兼容所选版本/加载器"),
("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"),
("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"),
("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"),
("PackTitle", "Modpack import & export", "整合包导入 / 导出"), ("PackTitle", "Modpack import & export", "整合包导入 / 导出"),
("PackImportTitle", "Import a modpack", "导入整合包"), ("PackImportTitle", "Import a modpack", "导入整合包"),
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
+10
View File
@@ -284,6 +284,16 @@ pub struct ContentInstallRequest {
pub game_version: String, pub game_version: String,
/// 目标实例的加载器(选文件用) /// 目标实例的加载器(选文件用)
pub loader: LoaderKind, pub loader: LoaderKind,
/// **只重试上次失败的那些项目**(M191)。
///
/// `true` 时 host 不再解根项目:它拿上一次装入留下的依赖计划调
/// `DependencyPlan::retry_request()`,只重解失败项(`present` 重新扫一遍目录),于是
/// 「已装好的不重下、没失败的不重发」。这种模式下本结构里**只有 `instance` 会被读**——
/// 过滤条件与落盘目录都来自那份计划。
///
/// 没有可重试的计划时(换进程 / 那一次是别的实例)一律回一条失败,**不**退化成「整单重装」:
/// 那正是本字段要避免的事。
pub retry: bool,
} }
/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。 /// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。
@@ -34,6 +34,12 @@ pub enum ContentFailureKind {
Server, Server,
/// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对) /// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对)
Unsupported, Unsupported,
/// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`)
///
/// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救,
/// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档),
/// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。
Incompatible,
/// 其它(解析失败、本地 IO……) /// 其它(解析失败、本地 IO……)
Other, Other,
} }
@@ -47,10 +53,17 @@ impl ContentFailureKind {
Self::NotFound => "not_found", Self::NotFound => "not_found",
Self::Server => "server", Self::Server => "server",
Self::Unsupported => "unsupported", Self::Unsupported => "unsupported",
Self::Incompatible => "incompatible",
Self::Other => "other", Self::Other => "other",
} }
} }
/// 是不是「所选版本/加载器下没有条目」(换版本/加载器才有救,重试没用)。
#[must_use]
pub const fn is_incompatible(self) -> bool {
matches!(self, Self::Incompatible)
}
/// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。 /// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。
#[must_use] #[must_use]
pub const fn is_offline(self) -> bool { pub const fn is_offline(self) -> bool {
+111
View File
@@ -47,6 +47,93 @@ impl InstallStage {
} }
} }
/// 一条可只重试的失败的**档位**(M191;与 core 的 `FailureKind` 一一对应)。
///
/// ## 为什么不是一个 `bool` / 一句话
///
/// 「不兼容所选版本/加载器」与「取不到」要用户做的事不同:前者换版本或换加载器,后者检查网络
/// 再试。**两档在类型上分开**,界面才能分组画两段、给两条不同的话;合并成一档之后
/// 「重试」按钮会把一条重试必然失败的项也算进去(M191 的变异守门就钉这一条)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InstallFailureKind {
/// 站点有这个项目,但目标版本/加载器下没有条目(换版本/加载器才有救)
Incompatible,
/// 取不到(没联网 / 项目不存在 / 站点出错 / 下载失败)
Unreachable,
}
impl InstallFailureKind {
/// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Incompatible => "incompatible",
Self::Unreachable => "unreachable",
}
}
/// 两档的固定顺序(界面分组按它排:先「换版本能救」,再「检查网络」)。
pub const ALL: [Self; 2] = [Self::Incompatible, Self::Unreachable];
}
/// 一条「可只重试」的失败项(M191):项目键 + 可读标签 + 档位 + core 给的原话。
///
/// 这是界面画「可只重试这 N 个」和分组所需的**全部**信息:项目键就是重试时发出去的那个键
/// (`DependencyPlan::retry_request()` 用的同一份)。
///
/// ## 为什么还要 `label`(M191 r1 复审 P2-1)
///
/// `project` 是站点 id(`P7dR8mSH`),直接画在界面上等于给用户一串哈希。可读名其实都在手边,
/// 只是没进 DTO:根项目的名字就在请求的 `title` 里;失败文件的名字在 `PlannedNode.file.filename`
/// 里(下载失败那种)。`label` 收的正是这两个**语言中立**的字符串(站点标题 / 文件名),
/// **不是** `detail` 那种 core 中文散文——那会踩 i18n 的 R2(core 中文不进界面)。
///
/// `label` 为空是合法且**必须**保留的一档:项目不存在(404)时确实没有名字可显示,
/// 那时界面只画 id(评审认这条兜底是合理的)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallFailureView {
/// 请求它用的项目键(slug 或 id;重试也用它)
pub project: String,
/// 语言中立的可读标签(站点给的项目标题 / 文件名;空 = 拿不到名字)
pub label: String,
/// 哪一档
pub kind: InstallFailureKind,
/// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案;**不要**画到界面上)
pub detail: String,
}
impl InstallFailureView {
/// 构造。
#[must_use]
pub fn new(
project: impl Into<String>,
label: impl Into<String>,
kind: InstallFailureKind,
detail: impl Into<String>,
) -> Self {
Self {
project: project.into(),
label: label.into(),
kind,
detail: detail.into(),
}
}
/// 界面该画的那一行:有名字就「名字 (id)」,没有就只画 id。
///
/// 界面上**名字优先、id 次要**:名字让人认出是哪个项目,括号里的 id 是给「名字对不上时」
/// 或排查用的。定在这个方法里而不是视图里,是因为「有名字用名字」这条要有用例钉住。
#[must_use]
pub fn line(&self) -> String {
let label = self.label.trim();
if label.is_empty() || label == self.project {
self.project.clone()
} else {
format!("{label} ({})", self.project)
}
}
}
/// 一次安装的进度快照。 /// 一次安装的进度快照。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallProgressView { pub struct InstallProgressView {
@@ -77,6 +164,12 @@ pub struct InstallProgressView {
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与 /// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。 /// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
pub failure_kind: Option<ContentFailureKind>, pub failure_kind: Option<ContentFailureKind>,
/// 可只重试的失败项(M191):空 = 没有失败项 ⇒ 界面**不渲染**那条入口。
///
/// 每条自带档位([`InstallFailureKind`]),界面按档分组;`project` 就是
/// `DependencyPlan::retry_request()` 会发出去的那几个项目键——名单由 plan 产出,
/// 界面不自己拼(拼一份就会与重试实际请求的那几个不一致)。
pub retry: Vec<InstallFailureView>,
} }
impl Default for InstallProgressView { impl Default for InstallProgressView {
@@ -101,6 +194,7 @@ impl InstallProgressView {
eta_secs: 0, eta_secs: 0,
message: String::new(), message: String::new(),
failure_kind: None, failure_kind: None,
retry: Vec::new(),
} }
} }
@@ -159,6 +253,23 @@ impl InstallProgressView {
} }
format!("{}/{} 文件", self.files_done, self.files_total) format!("{}/{} 文件", self.files_done, self.files_total)
} }
/// 有可只重试的失败项吗(`false` ⇒ 界面**不渲染**那条入口,不是置灰)。
#[must_use]
pub fn has_retry(&self) -> bool {
!self.retry.is_empty()
}
/// 某一档的可重试项(两档分开画时用;顺序沿用计划里的顺序)。
pub fn retry_of(&self, kind: InstallFailureKind) -> impl Iterator<Item = &InstallFailureView> {
self.retry.iter().filter(move |item| item.kind == kind)
}
/// 某一档有几项。
#[must_use]
pub fn retry_count(&self, kind: InstallFailureKind) -> usize {
self.retry_of(kind).count()
}
} }
/// 字节数 → 人读(`1.2 MB`)。 /// 字节数 → 人读(`1.2 MB`)。
+4 -1
View File
@@ -45,7 +45,10 @@ pub use content::{
ContentSource, ContentState, ContentVersionView, ContentSource, ContentState, ContentVersionView,
}; };
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; pub use install::{
derive_speed, human_bytes, InstallFailureKind, InstallFailureView, InstallProgressView,
InstallStage,
};
pub use instances::{ pub use instances::{
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
+86 -40
View File
@@ -14,12 +14,18 @@
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | //! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | //! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
//! //!
//! ## 前置的两档失败与重试名单(M186) //! ## 前置的两档失败与重试名单(M186 / M191)
//! //!
//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」 //! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」
//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项 //! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次, //! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户点(M191 的界面入口)。
//! `DependencyPlan::retry_request()` 已经把请求备好了。 //!
//! ## 「只重试这些」真的只重试这些(M191)
//!
//! 上一轮失败时,那份 `DependencyPlan` 被 [`plan::remember`] 记下来;`ContentInstallRequest.retry`
//! 为真的第二次请求走 [`run`] 的重试入口——它调 core 的 `DependencyPlan::retry_request()`,
//! 于是重解**只请求失败的那几个项目**(`present` 重新扫一遍目录,已装好的不会被重下)。
//! 没有记录时回一条失败,**不**退化成「整单重装」:那会把已装好的再下一遍、请求数放大。
//! //!
//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断, //! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断,
//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进 //! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进
@@ -57,6 +63,10 @@ use crate::dto::{
use super::super::env::HostEnv; use super::super::env::HostEnv;
use super::super::job::{HostTask, JobSender}; use super::super::job::{HostTask, JobSender};
mod collect;
pub(super) use collect::{collect, Placement, Ports};
use super::{downloads, plan}; use super::{downloads, plan};
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 /// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
@@ -78,7 +88,8 @@ pub(super) fn install(
// 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的, // 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的,
// 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。 // 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。
if request.kind.dir().is_none() { // 重试模式不看 `kind`:那种模式下落盘目录来自上一次的计划(见 `run`)。
if !request.retry && request.kind.dir().is_none() {
fail( fail(
tx, tx,
progress, progress,
@@ -134,6 +145,13 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu
/// ///
/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由 /// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由
/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。 /// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。
///
/// 两条解析入口,别混:
///
/// | 入口 | 请求从哪来 | 发几次 |
/// |---|---|---|
/// | 首次装入 | 根项目 + 过滤条件([`plan::request`]) | 一次,从根开始 BFS |
/// | **只重试失败项** | 上一轮计划的 `retry_request()`([`plan::retry_plan`]) | 一次,**只含失败的那几个项目** |
fn run( fn run(
env: &HostEnv, env: &HostEnv,
request: &ContentInstallRequest, request: &ContentInstallRequest,
@@ -143,6 +161,47 @@ fn run(
epoch: u64, epoch: u64,
) -> Result<String, ContentFailure> { ) -> Result<String, ContentFailure> {
let instance = find_instance(env, &request.instance)?; let instance = find_instance(env, &request.instance)?;
// 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。
// 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。
if request.retry {
let Some(saved) = plan::remembered(env, &request.instance) else {
return Err(local(RETRY_LOST));
};
// 面板头继续显示那次的显示名(不是项目 id:r1 复审 N5)
progress.version = saved.title.clone();
let present = plan::present_files(&saved.dir);
let core_engine = super::super::engine::engine(env);
let dependency_plan = plan::retry_plan(&saved.plan, present, |retry| {
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
block_on(resolve(&client, retry))
});
let fetcher = EngineArtifactFetcher::new(&core_engine);
let title = |id: &str| plan::fetch_title(env, id);
let ports = Ports {
env,
fetcher: &fetcher,
title: &title,
};
return collect(
&ports,
request,
dependency_plan,
Placement {
dir: &saved.dir,
root: plan::Root {
id: &saved.root,
title: &saved.title,
},
},
cancel,
progress,
tx,
epoch,
);
}
let game_dir = game_dir_of(env, &instance)?; let game_dir = game_dir_of(env, &instance)?;
let dir = request let dir = request
.kind .kind
@@ -165,53 +224,40 @@ fn run(
plan::present_files(&target_dir), plan::present_files(&target_dir),
); );
let core_engine = super::super::engine::engine(env); let core_engine = super::super::engine::engine(env);
let mut dependency_plan = { let dependency_plan = {
let transport = EngineTransport::new(&core_engine); let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport); let client = Modrinth::new(&transport);
block_on(resolve(&client, &resolve_request)) block_on(resolve(&client, &resolve_request))
}; };
progress.stage = InstallStage::Downloading;
progress.files_total = dependency_plan.nodes.len() as u64;
progress.files_done = 0;
progress.message = if dependency_plan.nodes.is_empty() {
"没有要下载的文件".to_string()
} else {
format!(
"{} 个文件(含 {} 个前置)",
dependency_plan.nodes.len(),
dependency_plan
.nodes
.iter()
.filter(|node| !node.is_root())
.count()
)
};
let _ = tx.send(HostTask::Install {
progress: Box::new(progress.clone()),
epoch,
});
let fetcher = EngineArtifactFetcher::new(&core_engine); let fetcher = EngineArtifactFetcher::new(&core_engine);
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。 let title = |id: &str| plan::fetch_title(env, id);
let installed = downloads::download_stage( let ports = Ports {
&mut dependency_plan, env,
&target_dir, fetcher: &fetcher,
&fetcher, title: &title,
};
collect(
&ports,
request,
dependency_plan,
Placement {
dir: &target_dir,
root: plan::Root {
id: &request.project,
title: &request.title,
},
},
cancel, cancel,
progress, progress,
tx, tx,
epoch, epoch,
)?; )
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
// 并列出「可只重试这 N 个」。
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
return Err(failure);
}
Ok(plan::summary(&dependency_plan, &installed))
} }
/// 没有可重试的记录时那句话(**不**退化成「整单重装」)。
const RETRY_LOST: &str =
"没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。";
/// 完成态:`files_done` 对齐 `files_total`。 /// 完成态:`files_done` 对齐 `files_total`。
/// ///
/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态 /// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态
@@ -0,0 +1,117 @@
//! 解析之后的公共尾巴:进度 → 逐个下载 → 收口(失败名单 / 计划留存)。
//!
//! 从 `install.rs` 拆出(r3 复审的接线守卫加完 `Ports` 之后父文件到 415 行,越过 400 行硬线)。
//! 拆法同既有 `-p1/-p2`:**纯搬移**,调用方路径不变(`install.rs` 仍 `pub(super) use` 这三个名字)。
//!
//! 为什么不把 `Ports` 再往里藏:`collect` 是**要能在用例里真跑**的那一段(r3 复审 p2:
//! 「失败路径真的去取了名字」原来零覆盖)。下载器与取名字器从参数进来,假实现就能驱动整条
//! 收口路径,不碰网络。
use std::path::Path;
use std::sync::atomic::AtomicBool;
use unlucky_core::addon::online::{ArtifactFetcher, DependencyPlan};
use crate::dto::{ContentFailure, ContentInstallRequest, InstallProgressView, InstallStage};
use crate::host::env::HostEnv;
use crate::host::job::{HostTask, JobSender};
use super::{downloads, plan};
/// `collect` 要用的「外部世界」:宿主环境 + 下载器 + 取名字器。
///
/// 三样都从这里进,是为了让**收口那段能在用例里真跑**:假下载器 + 假取名字器就能驱动
/// 「下载 → 收口 → 失败名单」整条路,于是「失败路径真的去取了不兼容档的名字」这件事有用例
/// 钉住(r3 复审 p2:那一行原来删掉也全绿)。
pub(in crate::host::addon) struct Ports<'a> {
/// 宿主环境(记「上一轮的计划」要用它的数据根)
pub(in crate::host::addon) env: &'a HostEnv,
/// 下载器(生产 = 引擎;用例 = 假实现,不碰网络)
pub(in crate::host::addon) fetcher: &'a dyn ArtifactFetcher,
/// 取项目显示名(生产 = [`plan::fetch_title`];用例 = 假实现)
pub(in crate::host::addon) title: &'a dyn Fn(&str) -> Option<String>,
}
/// 「装到哪 + 那次装入的根是谁」——总是一起出现,捆成一个参数(否则 `collect` 顶破 8 个)。
pub(in crate::host::addon) struct Placement<'a> {
/// 落盘目录(`mods/` / `resourcepacks/` / `shaderpacks/`)
pub(in crate::host::addon) dir: &'a Path,
/// 当初那次装入的根项目(id + 显示名);重试轮里由 `plan::remembered` 提供
///
/// **必须**带 id:重试那一轮 `plan.request.roots` 是上一轮的失败名单,不能拿它判「是不是根」
/// (r2 复审 P2-2 就是这条:第一前置被贴上了根的标题)。
pub(in crate::host::addon) root: plan::Root<'a>,
}
/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。
///
/// 收口的两件事在同一个地方做,是因为它们必须同时发生:
///
/// | 结果 | 计划 | 可重试名单 |
/// |---|---|---|
/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) |
/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) |
pub(in crate::host::addon) fn collect(
ports: &Ports<'_>,
request: &ContentInstallRequest,
mut dependency_plan: DependencyPlan,
placement: Placement<'_>,
cancel: &AtomicBool,
progress: &mut InstallProgressView,
tx: &JobSender,
epoch: u64,
) -> Result<String, ContentFailure> {
progress.stage = InstallStage::Downloading;
progress.files_total = dependency_plan.nodes.len() as u64;
progress.files_done = 0;
progress.message = if dependency_plan.nodes.is_empty() {
"没有要下载的文件".to_string()
} else {
format!(
"{} 个文件(含 {} 个前置)",
dependency_plan.nodes.len(),
dependency_plan
.nodes
.iter()
.filter(|node| !node.is_root())
.count()
)
};
let _ = tx.send(HostTask::Install {
progress: Box::new(progress.clone()),
epoch,
});
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。
let installed = downloads::download_stage(
&mut dependency_plan,
placement.dir,
ports.fetcher,
cancel,
progress,
tx,
epoch,
)?;
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
// 并列出「可只重试这 N 个」。
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
// 「不兼容」那一档项目**存在**(站点只是没有匹配目标版本/加载器的条目),所以在这里
// 现取一次名字——只在失败路径上发、一个项目一次 GET,取不到就退回 id(见 `names_for_incompatible`)。
let names = plan::names_for_incompatible(&dependency_plan, ports.title);
progress.retry = plan::retry_items(&dependency_plan, placement.root, &names);
plan::remember(
ports.env,
&request.instance,
placement.dir,
placement.root.id,
placement.root.title,
&dependency_plan,
);
return Err(failure);
}
progress.retry.clear();
plan::forget(ports.env, &request.instance);
Ok(plan::summary(&dependency_plan, &installed))
}
+84 -9
View File
@@ -17,13 +17,20 @@
//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说 //! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说
//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。 //! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。
//! //!
//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误) //! # 「可只重试这些」的名单也在这里(M191)
//! //!
//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档 //! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 可读标签 +
//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`, //! 档位 + 原话),界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的
//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由 //! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。
//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的 //!
//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。 //! 可读标签由 [`label_of`] 定(根项目用请求标题、下载失败用文件名、解析失败留空),
//! **不许**拿 `failure.message` 顶上——那是 core 的中文散文,界面照画会踩 i18n R2。
//!
//! # 不兼容有自己的一档(M191 起)
//!
//! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说
//! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射,
//! **不再借 `Unsupported`**。
use std::path::Path; use std::path::Path;
@@ -32,10 +39,16 @@ use unlucky_core::addon::online::{
ResolveRequest, ResolveRequest,
}; };
use crate::dto::ContentFailureKind; use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView};
use super::sources::failure_of; use super::sources::failure_of;
mod labels;
mod remembered;
pub(super) use labels::{fetch_title, names_for_incompatible, Root};
pub(super) use remembered::{forget, remember, remembered};
/// 扫目标目录(不递归)里已存在的文件名。 /// 扫目标目录(不递归)里已存在的文件名。
/// ///
/// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的 /// 只给名字,不算摘要:为了「跳过」把整个 `mods/` 哈希一遍不划算,同名就是同一份 jar 的
@@ -98,7 +111,8 @@ fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind {
return wanted; return wanted;
} }
} }
ContentFailureKind::Unsupported // 只剩「不兼容」这一档:换版本/加载器才有救(M191 起它有自己的大类,不再借 `Unsupported`)
ContentFailureKind::Incompatible
} }
/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。 /// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。
@@ -106,10 +120,71 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind {
match failure.cause { match failure.cause {
Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind, Some(cause) => failure_of(OnlineError::new(cause, failure.message.clone())).kind,
// 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」 // 不兼容那一档没有网络成因:它要用户换版本/加载器,不是「再试一次」
None => ContentFailureKind::Unsupported, None => ContentFailureKind::Incompatible,
} }
} }
/// 可只重试的失败项 → DTO 名单(M191;界面按档分组画的那几条)。
///
/// 档位**一一映射** core 的 [`FailureKind`]:两档在 DTO 上分开,界面才有两段文案。
/// 顺序沿用计划里的顺序(`record_failure` 已按项目键去重,同一项目只出现一次)。
#[must_use]
pub(super) fn retry_items(
plan: &DependencyPlan,
root: labels::Root<'_>,
names: &labels::Names,
) -> Vec<InstallFailureView> {
plan.failures
.iter()
.map(|failure| {
InstallFailureView::new(
failure.requested_as.clone(),
labels::label_of(plan, failure, root, names),
failure_kind_of(failure.kind),
failure.message.clone(),
)
})
.collect()
}
/// core 的档位 → DTO 的档位(**穷尽 match**:谁把两档合并,这条会报错或让用例红)。
const fn failure_kind_of(kind: FailureKind) -> InstallFailureKind {
match kind {
FailureKind::Incompatible => InstallFailureKind::Incompatible,
FailureKind::Unreachable => InstallFailureKind::Unreachable,
}
}
/// 下一次解析的请求:**只含失败项**(M191)。
///
/// 这就是「只重试这些」的全部实现——请求来自 core 的
/// `DependencyPlan::retry_request()`(它只把失败项列进 `roots`),过滤条件沿用上次那份。
/// `present` 必须**重新扫**:这一轮新落盘的文件不在旧名单里,不重扫会被当成「还没有」再下一份
/// (core 的 `retry_request` 文档明确要求)。
///
/// 拿不到计划时(换进程 / 那次是别的实例)调用方**不许**退回「整单重装」:那会把已经装好的
/// 文件再下一遍,且请求数放大——正是本函数要避免的行为。
#[must_use]
pub(super) fn retry_request(plan: &DependencyPlan, present: Vec<PresentFile>) -> ResolveRequest {
ResolveRequest {
present,
..plan.retry_request()
}
}
/// 用「只含失败项」的请求重新解析(**生产**用它;用例注入假解析器断言只请求了哪几个项目)。
///
/// 解析器用闭包注入而不是直接调 core:这样「重试请求里到底有哪几个项目、发了几次」在没有网络
/// 的用例里也能钉住(M191 的变异守门:把 `retry_request()` 换成根项目请求,用例必须红)。
pub(super) fn retry_plan(
plan: &DependencyPlan,
present: Vec<PresentFile>,
resolve: impl FnOnce(&ResolveRequest) -> DependencyPlan,
) -> DependencyPlan {
let request = retry_request(plan, present);
resolve(&request)
}
/// 失败清单(两档各起一段,各自的条数分开数)。 /// 失败清单(两档各起一段,各自的条数分开数)。
fn report(plan: &DependencyPlan, installed: usize) -> String { fn report(plan: &DependencyPlan, installed: usize) -> String {
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
@@ -0,0 +1,173 @@
//! 「这条失败该显示什么名字」——**语言中立**的标签来源(M191 r1 P2-1 / r2 P2-1 续)。
//!
//! 从 `plan.rs` 拆出(加完失败路径上取名字那次 GET 之后父文件到 414 行,越过 400 行硬线)。
//! 拆的口径是「一件事一个文件」:这里只管**名字从哪来**,判定与说话仍在 `plan.rs`。
//!
//! 三个来源,按可信度排(细节在 [`label_of`]):原始根项目的标题、失败文件的文件名、
//! 失败路径上现取的项目名;都没有就留空,界面只画项目 id。
//!
//! **不许**拿 `DependencyFailure::message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。
use unlucky_core::addon::online::{
DependencyFailure, DependencyPlan, EngineTransport, FailureKind, Modrinth, OnlineError,
ProjectDetail,
};
use unlucky_core::download::blocking::block_on;
use super::super::super::env::HostEnv;
/// 这一次装入的**原始根项目**(id + 显示名)。
///
/// 「是不是根」只能按 `id` 比(r2 复审 P2-2):重试那一轮的 plan 来自
/// `DependencyPlan::retry_request()`,它的 `request.roots` **就是上一轮的失败名单**——
/// 第一个是前置,拿它当「根」会把根的标题贴到前置头上。所以判据是「这条失败的 id 是不是
/// 当初那次装入的根 id」,两个值都由 [`remembered`] / `ContentInstallRequest` 提供。
#[derive(Clone, Copy)]
pub(in crate::host::addon) struct Root<'a> {
/// 当初那次装入的根项目 id(`ContentInstallRequest.project`)
pub id: &'a str,
/// 它的显示名(`ContentInstallRequest.title`;只配给 `id` 用)
pub title: &'a str,
}
/// 失败路径上现取的项目名(id → title)。取不到的项目不在表里。
pub(in crate::host::addon) type Names = Vec<(String, String)>;
/// 为「不兼容」档现取项目名(**只在失败路径上**,一个项目一次 GET)。
///
/// 为什么只给这一档:`FailureKind::Incompatible` 是「站点**有这个项目**,但目标版本/加载器下
/// 没有条目」——项目确实存在,所以这次 GET 有东西可拿。「取不到」档里 404 的那部分项目根本
/// 不存在,给它发请求只会再吃一个 404,于是那一档仍显示 id(合理兜底)。
///
/// `lookup` 由调用方注入(生产是 `Modrinth::project`,用例是闭包):于是「取名字失败也要能降级」
/// 这件事在没有网络的用例里也能钉住。取不到 / 拿到空串的项一律**不进**名单。
pub(in crate::host::addon) fn names_for_incompatible(
plan: &DependencyPlan,
lookup: impl Fn(&str) -> Option<String>,
) -> Names {
plan.failures_of(FailureKind::Incompatible)
.filter_map(|failure| {
let title = lookup(&failure.requested_as)?;
let title = title.trim();
(!title.is_empty()).then(|| (failure.requested_as.clone(), title.to_string()))
})
.collect()
}
/// 一条失败的可读标签(**语言中立**:站点标题、文件名或现取的项目名;拿不到就是空串)。
///
/// 三个来源,**按可信度排**:
///
/// | 情况 | 标签 | 为什么 |
/// |---|---|---|
/// | 这条失败的 id == 原始根 id | 请求里的 `title` | 用户刚才点的就是它 |
/// | 计划里有这个节点(下载失败) | `node.file.filename` | 「哪个文件没下来」比项目名更具体 |
/// | 不兼容档 | [`names_for_incompatible`] 现取的名字 | 项目存在,一次 GET 拿得到 |
/// | 其余(解析 404 等) | 空 | 项目不存在,显示 id 是合理兜底 |
///
/// **不许**拿 `failure.message` 顶:那是 core 的中文散文,画到界面上就是 i18n R2 的违规。
#[must_use]
pub(super) fn label_of(
plan: &DependencyPlan,
failure: &DependencyFailure,
root: Root<'_>,
names: &Names,
) -> String {
// ① 根项目自己失败:**按 id 比**,不看 `plan.request.roots`(重试轮里那是失败名单)
if failure.requested_as == root.id {
return root.title.to_string();
}
// ② 计划里有这个节点(下载失败):文件名最具体
if let Some(node) = plan
.nodes
.iter()
.find(|node| node.requested_as == failure.requested_as)
{
return node.file.filename.clone();
}
// ③ 不兼容档:失败路径上现取的名字;取不到就空 ⇒ 界面只画 id
if failure.kind == FailureKind::Incompatible {
return names
.iter()
.find(|(id, _)| id == &failure.requested_as)
.map(|(_, title)| title.clone())
.unwrap_or_default();
}
String::new()
}
/// 取一个项目的显示名(**只在失败路径上**发)。
///
/// 一次 GET,折成「有名字 / 没名字」的那一步在 [`title_from`](那里才是能坏的地方,因此那一步
/// 单独可测;本函数只有「怎么发这一枪」)。
pub(in crate::host::addon) fn fetch_title(env: &HostEnv, id: &str) -> Option<String> {
let core_engine = crate::host::engine::engine(env);
let transport = EngineTransport::new(&core_engine);
let client = Modrinth::new(&transport);
title_from(|id| block_on(async { client.project(id).await }), id)
}
/// 把「一次项目查询」折成「有名字 / 没名字」——**降级就在这一层**(r3 复审 p2 要的守卫)。
///
/// 三种「没名字」都算 `None`,一个都不许往外冒:
///
/// | 情况 | 结果 |
/// |---|---|
/// | 查询失败(没网 / 404 / 坏响应) | `None`(**不是 panic,也不是错误**) |
/// | 站点给了空标题 | `None` |
/// | 站点给了全空白标题 | `None` |
///
/// 为什么单独抽出来:这条链路上唯一会「坏」的就是这一句折叠。抽成参数化的纯函数之后,
/// 「查询失败仍退回 id」与「空白不算名字」都能在没有网络的用例里钉住(变异:`ok` 换 `expect`
/// ⇒ 红;去掉空串过滤 ⇒ 红)。
fn title_from(
query: impl FnOnce(&str) -> Result<ProjectDetail, OnlineError>,
id: &str,
) -> Option<String> {
query(id)
.ok()
.map(|project| project.title)
.filter(|title| !title.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn detail(title: &str) -> ProjectDetail {
ProjectDetail {
id: "P7dR8mSH".to_string(),
title: title.to_string(),
..ProjectDetail::default()
}
}
/// **降级层**:查询失败 / 空标题 / 全空白 ⇒ 一律 `None`(界面退回只画 id),不 panic。
///
/// 抓得住:把 `.ok()` 换成 `expect`(查询失败即 panic)⇒ 第一条红;
/// 去掉 `.filter(..)`(空白也算名字)⇒ 后两条红。
#[test]
fn a_failed_or_blank_lookup_yields_no_name() {
// ① 查询失败(没网 / 404 / 坏响应)
assert!(title_from(|_| Err(OnlineError::offline("断网")), "P7dR8mSH").is_none());
// ② 站点给了空标题
assert!(title_from(|_| Ok(detail("")), "P7dR8mSH").is_none());
// ③ 站点给了全空白标题
assert!(title_from(|_| Ok(detail(" ")), "P7dR8mSH").is_none());
// ④ 正常情况:原样给出
assert_eq!(
title_from(|_| Ok(detail("Entity Culling")), "P7dR8mSH"),
Some("Entity Culling".to_string())
);
// 传进去的 id 就是查询用的那个(不要拿别的键去问)
let mut asked = String::new();
let _ = title_from(
|id| {
asked.push_str(id);
Ok(detail("X"))
},
"P7dR8mSH",
);
assert_eq!(asked, "P7dR8mSH");
}
}
@@ -0,0 +1,106 @@
//! 「上一次装入留下的计划」表(M191):`retry_plan()` 的输入。
//!
//! 从 `plan.rs` 拆出(父文件加了 `label_of` 之后顶到 429 行,越过 400 行硬线)。
//! 拆法与本仓既有 `-p1/-p2` 一致:**纯搬移**,一行不改语义;调用方路径不变
//! (`plan.rs` 仍然 `pub(super) use` 这三个函数)。
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use unlucky_core::addon::online::DependencyPlan;
use super::super::super::env::HostEnv;
/// 「上一次装入留下的计划」表的一行(M191):`只重试这些` 的输入。
///
/// ## 为什么要一张静态表(说清楚这笔债)
///
/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在
/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与
/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host
/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。
///
/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。
/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。
///
/// ## 为什么连**原始根项目 id** 一起记(r2 复审 P2-2)
///
/// 「这一条是不是根项目自己失败」不能靠 `plan.request.roots.first()` 判:重试那一轮的 plan 来自
/// `DependencyPlan::retry_request()`,它的 `roots` 就是**上一轮的失败名单**——第一个是前置,
/// 拿它跟根的显示名一比,就会把 `Sodium` 贴到一个前置头上。所以判据只能是「这个 id 是不是
/// 当初那次装入的根 id」,那个 id 得跟计划一起记下来。
struct Entry {
host: PathBuf,
instance: String,
/// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录)
dir: PathBuf,
/// 当初那次装入的根项目 id(`ContentInstallRequest.project`,判「是不是根」的唯一依据)
root: String,
/// 当初那次装入的显示名(只配给 `root` 用)
title: String,
plan: DependencyPlan,
}
static REMEMBERED: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
/// 取回的那一份记录(拆成结构体而不是四元组:调用点读得懂哪一个是哪个)。
pub(in crate::host::addon) struct Remembered {
/// 落盘目录
pub dir: PathBuf,
/// 当初那次装入的根项目 id
pub root: String,
/// 当初那次装入的显示名
pub title: String,
/// 上一轮的依赖计划(`retry_request()` 的来源)
pub plan: DependencyPlan,
}
/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。
pub(in crate::host::addon) fn remember(
env: &HostEnv,
instance: &str,
dir: &Path,
root: &str,
title: &str,
plan: &DependencyPlan,
) {
let host = env.layout.home().to_path_buf();
let mut table = REMEMBERED
.lock()
.unwrap_or_else(|poison| poison.into_inner());
table.retain(|item| !(item.host == host && item.instance == instance));
table.push(Entry {
host,
instance: instance.to_string(),
dir: dir.to_path_buf(),
root: root.to_string(),
title: title.to_string(),
plan: plan.clone(),
});
}
/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。
pub(in crate::host::addon) fn forget(env: &HostEnv, instance: &str) {
let host = env.layout.home().to_path_buf();
let mut table = REMEMBERED
.lock()
.unwrap_or_else(|poison| poison.into_inner());
table.retain(|item| !(item.host == host && item.instance == instance));
}
/// 这个实例上一次失败的计划(+ 落盘目录 + 原始根 id 与显示名);`None` = 没有可重试的记录。
pub(in crate::host::addon) fn remembered(env: &HostEnv, instance: &str) -> Option<Remembered> {
let host = env.layout.home().to_path_buf();
let table = REMEMBERED
.lock()
.unwrap_or_else(|poison| poison.into_inner());
table
.iter()
.find(|item| item.host == host && item.instance == instance)
.map(|item| Remembered {
dir: item.dir.clone(),
root: item.root.clone(),
title: item.title.clone(),
plan: item.plan.clone(),
})
}
+7 -1
View File
@@ -192,7 +192,10 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() {
Vec::new(), Vec::new(),
); );
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条"); let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
assert_eq!(failure.kind, ContentFailureKind::Unsupported); // M191:「不兼容」有了自己的大类(以前借 `Unsupported`,与「缺密钥」共用一档)
assert_eq!(failure.kind, ContentFailureKind::Incompatible);
assert!(failure.kind.is_incompatible());
assert!(!failure.is_offline());
assert!(failure.detail.contains("不兼容所选版本/加载器 1 个")); assert!(failure.detail.contains("不兼容所选版本/加载器 1 个"));
assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH")); assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH"));
assert!(!failure.detail.contains("下载失败"), "{}", failure.detail); assert!(!failure.detail.contains("下载失败"), "{}", failure.detail);
@@ -377,3 +380,6 @@ fn a_required_dependency_without_a_project_id_is_reported() {
"{message}" "{message}"
); );
} }
// M191:「只重试这些」的名单与重试路径(按主题分卷,父文件已贴 400 行硬线)
mod retry;
@@ -0,0 +1,332 @@
//! M191:「只重试这些」的两档名单 + 真重试路径(`super` 的用例按主题分卷,这一卷专管重试)。
//!
//! 三条判据对应的变异守门在各自的文档里写清了「改了什么这条会红」。
use super::{failure, node, plan};
use crate::dto::{InstallFailureKind, InstallFailureView};
use crate::host::addon::plan::{
failure_of_plan, forget, names_for_incompatible, remember, remembered, retry_items, retry_plan,
retry_request, Root,
};
use unlucky_core::addon::online::{DependencyPlan, FailureKind, OnlineErrorKind, PresentFile};
/// 一份「根装好了、两个前置失败」的计划(重试路径的典型输入)。
fn plan_with_two_tier_failures() -> DependencyPlan {
plan(
vec![
failure(
"P7dR8mSH",
FailureKind::Incompatible,
None,
"没有匹配的版本",
),
failure(
"1eAoo2KR",
FailureKind::Unreachable,
Some(OnlineErrorKind::NotFound),
"取版本失败(online_not_found):HTTP 404",
),
],
vec![node("AANobbMI", "sodium.jar", 0, &[])],
Vec::new(),
)
}
/// 可重试名单:**两档分在类型上**,每条的 project 就是重试会发出去的那个键。
///
/// 抓得住:把 `Incompatible` 并进 `Unreachable`(两档合成一档)⇒ 这条立刻红。
#[test]
fn the_retry_list_keeps_the_two_tiers_apart() {
let items = retry_items(
&plan_with_two_tier_failures(),
Root {
id: "AANobbMI",
title: "Sodium",
},
&Vec::new(),
);
assert_eq!(items.len(), 2);
assert_eq!(
items
.iter()
.map(|item| (item.project.as_str(), item.kind))
.collect::<Vec<_>>(),
[
("P7dR8mSH", InstallFailureKind::Incompatible),
("1eAoo2KR", InstallFailureKind::Unreachable),
]
);
// 两档的名字**必须不同**(界面拿它分组、也是日志/用例里的稳定 id)
assert_ne!(
InstallFailureKind::Incompatible.as_str(),
InstallFailureKind::Unreachable.as_str()
);
}
/// 全成功 ⇒ 名单是空的(界面据此**不渲染**那条入口,而不是画一个灰按钮)。
#[test]
fn a_clean_plan_offers_nothing_to_retry() {
let clean = plan(
Vec::new(),
vec![node("AANobbMI", "sodium.jar", 0, &[])],
Vec::new(),
);
assert!(retry_items(
&clean,
Root {
id: "AANobbMI",
title: "Sodium",
},
&Vec::new(),
)
.is_empty());
assert!(failure_of_plan(&clean, 1).is_none());
}
/// 「只重试这些」**只请求失败的那几个项目**:根项目不再发一次,解析只跑一次。
///
/// 这条是 M191 的变异守门①:把 `retry_request()` 换成「重新装根」(`ResolveRequest::single(root)`
/// 或 `plan.request.clone()`)⇒ `roots` 里会出现 `AANobbMI`,这条红。
#[test]
fn retrying_asks_for_the_failed_projects_only() {
let plan = plan_with_two_tier_failures();
let present = vec![PresentFile::new("sodium.jar")];
let mut seen: Vec<unlucky_core::addon::online::ResolveRequest> = Vec::new();
let _ = retry_plan(&plan, present, |request| {
seen.push(request.clone());
// 假解析器:把请求的根原样回成节点(用例只关心「请求了谁、请求了几次」)
DependencyPlan {
request: request.clone(),
nodes: request
.roots
.iter()
.map(|root| node(root, "x.jar", 0, &[]))
.collect(),
skipped: Vec::new(),
failures: Vec::new(),
truncated: Vec::new(),
unresolved_required: Vec::new(),
}
});
assert_eq!(seen.len(), 1, "重试只发一次解析,请求数不许放大");
let roots = &seen[0].roots;
assert_eq!(roots, &["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]);
assert!(
!roots.contains(&"AANobbMI".to_string()),
"根项目已装好,重试不许再解它:{roots:?}"
);
// `present` 重新扫过(这一轮新落盘的文件要在里面,否则会被当成「还没有」再下一份)
assert_eq!(seen[0].present, vec![PresentFile::new("sodium.jar")]);
// 过滤条件沿用上次那份
assert_eq!(seen[0].game_version, plan.request.game_version);
}
/// 计划被记住 / 忘掉:键是「host + 实例」,成功之后不该留可重试的记录。
#[test]
fn the_last_failed_plan_is_remembered_per_instance() {
let root = std::env::temp_dir().join(format!("ull-m191-retry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let env = crate::host::HostEnv::at_root(&root);
assert!(remembered(&env, "survival").is_none());
let plan = plan_with_two_tier_failures();
let dir = root.join(".minecraft/mods");
remember(&env, "survival", &dir, "AANobbMI", "Sodium", &plan);
let saved = remembered(&env, "survival").expect("记住了这一次");
assert_eq!(saved.dir, dir);
// 原始根 id + 显示名跟着计划一起记住:重试时面板头用它,判「是不是根」也用它(r2 P2-2)
assert_eq!(saved.root, "AANobbMI");
assert_eq!(saved.title, "Sodium");
assert_eq!(saved.plan.failures.len(), 2);
// 别的实例不受影响(同一台机器上装过两个实例)
assert!(remembered(&env, "modded").is_none());
// 重试请求从「记住的计划」里取:仍然只含失败项
let request = retry_request(&saved.plan, Vec::new());
assert_eq!(request.roots, vec!["P7dR8mSH", "1eAoo2KR"]);
forget(&env, "survival");
assert!(remembered(&env, "survival").is_none());
let _ = std::fs::remove_dir_all(&root);
}
/// DTO 名单与「真重试请求」是**同一份**:界面画的 N 个 = 重试会请求的 N 个。
///
/// 抓得住:界面名单自己另拼一份(多列/少列一个项目)⇒ 这条红。
#[test]
fn the_dto_list_is_exactly_what_the_retry_request_asks_for() {
let plan = plan_with_two_tier_failures();
let items: Vec<InstallFailureView> = retry_items(
&plan,
Root {
id: "AANobbMI",
title: "Sodium",
},
&Vec::new(),
);
let request = retry_request(&plan, Vec::new());
assert_eq!(
items
.iter()
.map(|item| item.project.clone())
.collect::<Vec<_>>(),
request.roots
);
}
/// **P2-1 的守门**:面板上写的是可读名,不是裸项目 id。
///
/// 三个来源各一条(`plan::label_of` 的三种情况),而且都必须**语言中立**(站点标题 / 文件名,
/// 不是 core 的中文散文):
///
/// | 项 | 情况 | 期望那一行 |
/// |---|---|---|
/// | 根项目 | 它自己失败 | `Sodium (AANobbMI)` —— 名字来自请求的 `title` |
/// | 前置 | 下载失败(计划里有节点) | `fabric-api.jar (P7dR8mSH)` —— 名字来自文件名 |
/// | 前置 | 解析阶段 404(没有节点) | `1eAoo2KR` —— 拿不到名,只画 id(合理兜底) |
///
/// 变异守门:把 `label_of` 的标签置空(或 `retry_items` 传空串)⇒ 这条红。
#[test]
fn the_retry_list_shows_a_readable_name_not_just_the_project_id() {
let mut plan = plan(
vec![
// 根项目自己失败(下载失败):requested_as 与 roots[0] 相同
failure(
"AANobbMI",
FailureKind::Unreachable,
Some(OnlineErrorKind::NotConnected),
"下载 sodium.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
),
// 前置下载失败:计划里有它的节点 ⇒ 用文件名
failure(
"P7dR8mSH",
FailureKind::Unreachable,
Some(OnlineErrorKind::NotConnected),
"下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
),
// 前置解析 404:没有节点、也不是根 ⇒ 没有名字可显示
failure(
"1eAoo2KR",
FailureKind::Unreachable,
Some(OnlineErrorKind::NotFound),
"取版本失败(online_not_found):HTTP 404",
),
],
vec![
node("AANobbMI", "sodium-fabric-0.6.0.jar", 0, &[]),
node("P7dR8mSH", "fabric-api.jar", 1, &["AANobbMI"]),
],
Vec::new(),
);
// 根必须是请求里的那个键(`plan::request` 只放一个根)
plan.request = unlucky_core::addon::online::ResolveRequest::single("AANobbMI");
let items = retry_items(
&plan,
Root {
id: "AANobbMI",
title: "Sodium",
},
&Vec::new(),
);
let lines: Vec<String> = items.iter().map(InstallFailureView::line).collect();
assert_eq!(
lines,
vec![
"Sodium (AANobbMI)".to_string(),
"fabric-api.jar (P7dR8mSH)".to_string(),
// 拿不到名的那一档:只画 id,不编造、也不拿 core 的中文 message 顶上
"1eAoo2KR".to_string(),
]
);
// 标签是语言中立的:站点标题/文件名,不是 core 的中文散文
assert!(!items[0].label.contains("下载"), "{}", items[0].label);
assert!(!items[1].label.contains("失败"), "{}", items[1].label);
}
/// **r2 P2-2 的守门**:重试那一轮,第一条失败项**不许**被贴成原根项目的名字。
///
/// 形状就是复审给的那个:重试轮的 plan 来自 `retry_request()`,它的 `roots` 是上一轮的失败名单
/// (第一个是**前置**),而 `root` 记的是原始根 `AANobbMI / Sodium`。老实现拿 `roots.first()`
/// 判「是不是根」⇒ 画出 `Sodium (P7dR8mSH)`;现在按 id 比 ⇒ 第一条是它自己的名字(或 id)。
///
/// 变异守门:把 `label_of` 的判据换回 `plan.request.roots.first()` ⇒ 这条红。
#[test]
fn the_retry_round_does_not_paste_the_root_title_onto_a_dependency() {
let mut plan = plan_with_two_tier_failures();
// 重试请求:roots = 上一轮的失败名单
plan.request = unlucky_core::addon::online::ResolveRequest {
roots: vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()],
..plan.request.clone()
};
// 重试轮里没有被安装的节点(上一轮装好的不会再进计划),于是标签只能来自 id
let items = retry_items(
&plan,
Root {
id: "AANobbMI",
title: "Sodium",
},
&Vec::new(),
);
let lines: Vec<String> = items.iter().map(InstallFailureView::line).collect();
assert_eq!(lines, vec!["P7dR8mSH".to_string(), "1eAoo2KR".to_string()]);
assert!(
!lines.iter().any(|line| line.contains("Sodium")),
"重试轮把根的标题贴到前置上了:{lines:?}"
);
}
/// **r2 P2-1 续的守门**:不兼容档要显示项目名,不是 id。
///
/// 那一档的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),所以失败路径上值得
/// 现取一次名字。变异守门:把这次获取去掉(`names` 传空)⇒ 第一条退回 `P7dR8mSH`,这条红。
#[test]
fn an_incompatible_item_shows_the_project_name_once_it_is_known() {
let plan = plan_with_two_tier_failures();
let root = Root {
id: "AANobbMI",
title: "Sodium",
};
// 生产路径:`names_for_incompatible` 用注入的查询取名字(这里模拟"站点有回应")
let names = names_for_incompatible(&plan, |id| {
(id == "P7dR8mSH").then(|| "Entity Culling".to_string())
});
assert_eq!(
names,
vec![("P7dR8mSH".to_string(), "Entity Culling".to_string())],
"只给不兼容档取名字(404 那条不发请求)"
);
let items = retry_items(&plan, root, &names);
assert_eq!(items[0].line(), "Entity Culling (P7dR8mSH)");
// 「取不到」那档不受影响:没有节点、没有名字 ⇒ 只画 id
assert_eq!(items[1].line(), "1eAoo2KR");
}
/// **降级**:取名字本身失败,不许把安装结果搞坏,也不许编名字——照旧画 id。
///
/// 变异守门:让 `project_title` 的错误往外冒(`?` / `expect`)⇒ 这条连编译都过不了,
/// 或者(退回成「拿不到就 panic」)在这里红。
#[test]
fn a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything() {
let plan = plan_with_two_tier_failures();
// 站点 404 / 没网 / 坏响应:`lookup` 全是 `None`
let names = names_for_incompatible(&plan, |_| None);
assert!(names.is_empty(), "{names:?}");
let items = retry_items(
&plan,
Root {
id: "AANobbMI",
title: "Sodium",
},
&names,
);
assert_eq!(items[0].line(), "P7dR8mSH", "取不到名字就画 id,不编造");
// 拿到空串(站点给了个空标题)同样不算名字
let blank = names_for_incompatible(&plan, |_| Some(" ".to_string()));
assert!(blank.is_empty(), "{blank:?}");
}
// r3 复审 p2:生产接线与降级守卫(按主题分卷,父文件已贴 400 行硬线)
mod wire;
@@ -0,0 +1,115 @@
//! r3 复审 p2:**生产接线**与**降级**的守卫(`super` 按主题分卷)。
//!
//! 这一卷的用例驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器,
//! 于是「失败路径真的去取了不兼容档的名字」这件事在没有网络的用例里也能钉住。
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use unlucky_core::addon::online::{
ArtifactFetcher, FetchArgs, OnlineError, OnlineErrorKind, OnlineFuture,
};
use super::plan_with_two_tier_failures;
use crate::dto::{
ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, LoaderKind,
};
use crate::host::addon::install::{collect, Placement, Ports};
use crate::host::addon::plan::Root;
/// 假下载器:一律「写一行字节成功」——本组用例只关心收口那一半(下载阶段另有自己的用例)。
struct QuietFetcher;
impl ArtifactFetcher for QuietFetcher {
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
Box::pin(async move {
std::fs::write(args.dest, b"jar").map_err(|error| {
OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}"))
})?;
Ok(4)
})
}
}
fn request_for(instance: &str) -> ContentInstallRequest {
ContentInstallRequest {
project: "AANobbMI".to_string(),
title: "Sodium".to_string(),
kind: crate::dto::ContentKind::Mod,
source: ContentSource::Modrinth,
instance: instance.to_string(),
game_version: "1.20.1".to_string(),
loader: LoaderKind::Fabric,
retry: false,
}
}
/// **接线层守卫**(r3 复审 p2 第 1 条):安装失败路径**真的**去取了不兼容档的名字。
///
/// 这条驱动的是生产那个 `collect`(不是它的复制品):假下载器 + 假取名字器,
/// 于是一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的计划跑完后:
///
/// - 不兼容那条**拿到了名字**(`Entity Culling`);
/// - 取名字器只被**不兼容**那条问过一次(404 那条不发请求)。
///
/// 变异守门:把 `collect` 里那行 `names_for_incompatible(...)` 删掉/短路(传空名单)⇒ 红。
#[test]
fn the_failed_install_path_really_fetches_names_for_the_incompatible_items() {
let root = std::env::temp_dir().join(format!("ull-m191-wire-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join(".minecraft/mods");
std::fs::create_dir_all(&dir).expect("建目录");
let env = crate::host::HostEnv::at_root(&root);
let plan = plan_with_two_tier_failures();
let asked: Mutex<Vec<String>> = Mutex::new(Vec::new());
let title = |id: &str| {
asked.lock().expect("锁没中毒").push(id.to_string());
(id == "P7dR8mSH").then(|| "Entity Culling".to_string())
};
let fetcher = QuietFetcher;
let ports = Ports {
env: &env,
fetcher: &fetcher,
title: &title,
};
let (tx, _rx) = crate::host::job::channel();
let cancel = AtomicBool::new(false);
let mut progress = InstallProgressView::new();
let request = request_for("survival");
let result = collect(
&ports,
&request,
plan,
Placement {
dir: &dir,
root: Root {
id: "AANobbMI",
title: "Sodium",
},
},
&cancel,
&mut progress,
&tx,
1,
);
assert!(result.is_err(), "有失败项就该回一条失败");
let incompatible = progress
.retry
.iter()
.find(|item| item.kind == InstallFailureKind::Incompatible)
.expect("不兼容档要在名单里");
assert_eq!(
incompatible.line(),
"Entity Culling (P7dR8mSH)",
"失败路径没有把取到的名字贴上去"
);
assert_eq!(
*asked.lock().expect("锁没中毒"),
vec!["P7dR8mSH".to_string()],
"只该给不兼容档取名字(404 那条不发请求)"
);
let _ = std::fs::remove_dir_all(&root);
}