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
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:
commit
3116f43c9f
28 files changed
+1791
-55
No files matched your search
@@ -104,6 +104,15 @@ impl App {
|
||||
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`)
|
||||
pages::content::Message::Pack(message) => self.on_pack(message),
|
||||
}
|
||||
@@ -222,6 +231,8 @@ impl App {
|
||||
instance: target.id.clone(),
|
||||
game_version: target.mc_version.clone(),
|
||||
loader: target.loader,
|
||||
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,16 @@
|
||||
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 |
|
||||
//! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 |
|
||||
//! | 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 layout;
|
||||
@@ -61,7 +71,10 @@ mod view;
|
||||
mod tests;
|
||||
|
||||
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
|
||||
pub use packui::{request_export, Pack, PackMessage, PackPageState};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `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()` 根本不会被调用。
|
||||
|
||||
@@ -19,10 +19,52 @@ use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
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;
|
||||
|
||||
/// 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)。
|
||||
fn targets() -> Vec<Target> {
|
||||
vec![
|
||||
@@ -254,6 +296,12 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
} else {
|
||||
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()],
|
||||
available_versions: demo_versions,
|
||||
versions_note: None,
|
||||
@@ -265,3 +313,33 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
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,
|
||||
/// 装进目标实例
|
||||
Install,
|
||||
/// **只重试上次失败的那几项**(M191;不是「整单重装」)
|
||||
///
|
||||
/// app 层的落点:`AppCommand::InstallContent` 带 `retry: true`——host 拿上一轮的依赖计划
|
||||
/// 调 `DependencyPlan::retry_request()`,只重解失败项。
|
||||
RetryFailed,
|
||||
/// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`)
|
||||
Pack(super::packui::PackMessage),
|
||||
}
|
||||
@@ -32,10 +32,12 @@ use unlucky_i18n::{Catalog, Key};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
mod items;
|
||||
mod retry;
|
||||
|
||||
pub(crate) use items::HitFacts;
|
||||
pub(crate) use items::{capitalize, thousands};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
|
||||
pub use retry::{RetryGroup, RetryPanel};
|
||||
|
||||
/// 内容库页要画的东西。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -69,6 +71,11 @@ pub struct Content {
|
||||
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
|
||||
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
|
||||
pub panel: Option<Download>,
|
||||
/// 「只重试这些」面板(M191):`None` = 不渲染(全成功 / 没有失败项 / 还在跑)。
|
||||
///
|
||||
/// 与 `panel` 互斥地画:失败且有可重试项时,这块**顶替**下载页那块通用失败面板,
|
||||
/// 一屏只有一个失败块(两个会说同一件事,还各自给一个「关闭」)。
|
||||
pub retry: Option<RetryPanel>,
|
||||
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
|
||||
pub versions: Vec<String>,
|
||||
/// 关键词草稿
|
||||
@@ -187,6 +194,11 @@ impl Content {
|
||||
target_label,
|
||||
blockers,
|
||||
panel: content_install(state, catalog),
|
||||
// M191:失败且有可重试项时这块才存在(全成功 ⇒ `None` ⇒ 视图里连按钮都没有)
|
||||
retry: state
|
||||
.install
|
||||
.as_ref()
|
||||
.and_then(|progress| RetryPanel::of(progress, catalog)),
|
||||
versions,
|
||||
draft: page.draft.clone(),
|
||||
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,
|
||||
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
|
||||
failure_kind: None,
|
||||
// 导入没有「只重试这些」这条路径(重试整个导入由导入区自己管)。
|
||||
retry: Vec::new(),
|
||||
message: progress.current.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ use super::model::{Content, Hit};
|
||||
|
||||
mod detail;
|
||||
mod filters;
|
||||
mod retry;
|
||||
|
||||
pub(crate) use detail::detail;
|
||||
pub(crate) use filters::{filters, labelled};
|
||||
pub(crate) use retry::maybe as retry_panel;
|
||||
|
||||
use super::Message as ContentMessage;
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,12 @@ fn body<'a>(
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(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
|
||||
.push(divider::full(ui))
|
||||
|
||||
@@ -297,6 +297,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
|
||||
message: String::new(),
|
||||
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
|
||||
failure_kind: None,
|
||||
// 同理:没有失败名单 ⇒ 内容库那块「只重试这些」不出现(下载页也不画它)。
|
||||
retry: Vec::new(),
|
||||
},
|
||||
catalog,
|
||||
)
|
||||
|
||||
@@ -95,6 +95,29 @@ macro_rules! content_entries {
|
||||
"Installing from CurseForge is not available yet",
|
||||
"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)---
|
||||
PackTitle => ("Modpack import & export", "整合包导入 / 导出"),
|
||||
PackImportTitle => ("Import a modpack", "导入整合包"),
|
||||
|
||||
@@ -386,6 +386,11 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
|
||||
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "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", "整合包导入 / 导出"),
|
||||
("PackImportTitle", "Import a modpack", "导入整合包"),
|
||||
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
|
||||
|
||||
@@ -284,6 +284,16 @@ pub struct ContentInstallRequest {
|
||||
pub game_version: String,
|
||||
/// 目标实例的加载器(选文件用)
|
||||
pub loader: LoaderKind,
|
||||
/// **只重试上次失败的那些项目**(M191)。
|
||||
///
|
||||
/// `true` 时 host 不再解根项目:它拿上一次装入留下的依赖计划调
|
||||
/// `DependencyPlan::retry_request()`,只重解失败项(`present` 重新扫一遍目录),于是
|
||||
/// 「已装好的不重下、没失败的不重发」。这种模式下本结构里**只有 `instance` 会被读**——
|
||||
/// 过滤条件与落盘目录都来自那份计划。
|
||||
///
|
||||
/// 没有可重试的计划时(换进程 / 那一次是别的实例)一律回一条失败,**不**退化成「整单重装」:
|
||||
/// 那正是本字段要避免的事。
|
||||
pub retry: bool,
|
||||
}
|
||||
|
||||
/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。
|
||||
|
||||
@@ -34,6 +34,12 @@ pub enum ContentFailureKind {
|
||||
Server,
|
||||
/// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对)
|
||||
Unsupported,
|
||||
/// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`)
|
||||
///
|
||||
/// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救,
|
||||
/// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档),
|
||||
/// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。
|
||||
Incompatible,
|
||||
/// 其它(解析失败、本地 IO……)
|
||||
Other,
|
||||
}
|
||||
@@ -47,10 +53,17 @@ impl ContentFailureKind {
|
||||
Self::NotFound => "not_found",
|
||||
Self::Server => "server",
|
||||
Self::Unsupported => "unsupported",
|
||||
Self::Incompatible => "incompatible",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是不是「所选版本/加载器下没有条目」(换版本/加载器才有救,重试没用)。
|
||||
#[must_use]
|
||||
pub const fn is_incompatible(self) -> bool {
|
||||
matches!(self, Self::Incompatible)
|
||||
}
|
||||
|
||||
/// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。
|
||||
#[must_use]
|
||||
pub const fn is_offline(self) -> bool {
|
||||
|
||||
@@ -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)]
|
||||
pub struct InstallProgressView {
|
||||
@@ -77,6 +164,12 @@ pub struct InstallProgressView {
|
||||
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
|
||||
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
|
||||
pub failure_kind: Option<ContentFailureKind>,
|
||||
/// 可只重试的失败项(M191):空 = 没有失败项 ⇒ 界面**不渲染**那条入口。
|
||||
///
|
||||
/// 每条自带档位([`InstallFailureKind`]),界面按档分组;`project` 就是
|
||||
/// `DependencyPlan::retry_request()` 会发出去的那几个项目键——名单由 plan 产出,
|
||||
/// 界面不自己拼(拼一份就会与重试实际请求的那几个不一致)。
|
||||
pub retry: Vec<InstallFailureView>,
|
||||
}
|
||||
|
||||
impl Default for InstallProgressView {
|
||||
@@ -101,6 +194,7 @@ impl InstallProgressView {
|
||||
eta_secs: 0,
|
||||
message: String::new(),
|
||||
failure_kind: None,
|
||||
retry: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +253,23 @@ impl InstallProgressView {
|
||||
}
|
||||
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`)。
|
||||
|
||||
@@ -45,7 +45,10 @@ pub use content::{
|
||||
ContentSource, ContentState, ContentVersionView,
|
||||
};
|
||||
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::{
|
||||
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
|
||||
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
|
||||
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
|
||||
//!
|
||||
//! ## 前置的两档失败与重试名单(M186)
|
||||
//! ## 前置的两档失败与重试名单(M186 / M191)
|
||||
//!
|
||||
//! 解析在 [`super::plan`] + `core::addon::online::resolve`:某个前置「不兼容所选版本/加载器」
|
||||
//! 与「取不到(网络 / 项目不存在)」是**两条**记录,本文件把两档分开写进失败文案,并把失败项
|
||||
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次,
|
||||
//! `DependencyPlan::retry_request()` 已经把请求备好了。
|
||||
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户点(M191 的界面入口)。
|
||||
//!
|
||||
//! ## 「只重试这些」真的只重试这些(M191)
|
||||
//!
|
||||
//! 上一轮失败时,那份 `DependencyPlan` 被 [`plan::remember`] 记下来;`ContentInstallRequest.retry`
|
||||
//! 为真的第二次请求走 [`run`] 的重试入口——它调 core 的 `DependencyPlan::retry_request()`,
|
||||
//! 于是重解**只请求失败的那几个项目**(`present` 重新扫一遍目录,已装好的不会被重下)。
|
||||
//! 没有记录时回一条失败,**不**退化成「整单重装」:那会把已装好的再下一遍、请求数放大。
|
||||
//!
|
||||
//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断,
|
||||
//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进
|
||||
@@ -57,6 +63,10 @@ use crate::dto::{
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
mod collect;
|
||||
|
||||
pub(super) use collect::{collect, Placement, Ports};
|
||||
|
||||
use super::{downloads, plan};
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
@@ -78,7 +88,8 @@ pub(super) fn install(
|
||||
|
||||
// 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的,
|
||||
// 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。
|
||||
if request.kind.dir().is_none() {
|
||||
// 重试模式不看 `kind`:那种模式下落盘目录来自上一次的计划(见 `run`)。
|
||||
if !request.retry && request.kind.dir().is_none() {
|
||||
fail(
|
||||
tx,
|
||||
progress,
|
||||
@@ -134,6 +145,13 @@ fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailu
|
||||
///
|
||||
/// 返回「装了哪些 / 跳过了哪些」的人读描述;解析阶段的两档失败由
|
||||
/// [`plan::failure_of_plan`] 变成一条带大类的失败(**前置缺了不算装成**)。
|
||||
///
|
||||
/// 两条解析入口,别混:
|
||||
///
|
||||
/// | 入口 | 请求从哪来 | 发几次 |
|
||||
/// |---|---|---|
|
||||
/// | 首次装入 | 根项目 + 过滤条件([`plan::request`]) | 一次,从根开始 BFS |
|
||||
/// | **只重试失败项** | 上一轮计划的 `retry_request()`([`plan::retry_plan`]) | 一次,**只含失败的那几个项目** |
|
||||
fn run(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
@@ -143,6 +161,47 @@ fn run(
|
||||
epoch: u64,
|
||||
) -> Result<String, ContentFailure> {
|
||||
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 dir = request
|
||||
.kind
|
||||
@@ -165,53 +224,40 @@ fn run(
|
||||
plan::present_files(&target_dir),
|
||||
);
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let mut dependency_plan = {
|
||||
let dependency_plan = {
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
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);
|
||||
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。
|
||||
let installed = downloads::download_stage(
|
||||
&mut dependency_plan,
|
||||
&target_dir,
|
||||
&fetcher,
|
||||
let title = |id: &str| plan::fetch_title(env, id);
|
||||
let ports = Ports {
|
||||
env,
|
||||
fetcher: &fetcher,
|
||||
title: &title,
|
||||
};
|
||||
collect(
|
||||
&ports,
|
||||
request,
|
||||
dependency_plan,
|
||||
Placement {
|
||||
dir: &target_dir,
|
||||
root: plan::Root {
|
||||
id: &request.project,
|
||||
title: &request.title,
|
||||
},
|
||||
},
|
||||
cancel,
|
||||
progress,
|
||||
tx,
|
||||
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`。
|
||||
///
|
||||
/// 别在这里另写数字:以前写死 `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))
|
||||
}
|
||||
@@ -17,13 +17,20 @@
|
||||
//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说
|
||||
//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。
|
||||
//!
|
||||
//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误)
|
||||
//! # 「可只重试这些」的名单也在这里(M191)
|
||||
//!
|
||||
//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档
|
||||
//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`,
|
||||
//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由
|
||||
//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的
|
||||
//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。
|
||||
//! [`retry_items`] 把计划里的失败项摊成 DTO 的 [`InstallFailureView`](项目键 + 可读标签 +
|
||||
//! 档位 + 原话),界面据此分组画两段;[`retry_plan`] 是**真正重试**那一步——它调 core 的
|
||||
//! `DependencyPlan::retry_request()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。
|
||||
//!
|
||||
//! 可读标签由 [`label_of`] 定(根项目用请求标题、下载失败用文件名、解析失败留空),
|
||||
//! **不许**拿 `failure.message` 顶上——那是 core 的中文散文,界面照画会踩 i18n R2。
|
||||
//!
|
||||
//! # 不兼容有自己的一档(M191 起)
|
||||
//!
|
||||
//! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说
|
||||
//! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射,
|
||||
//! **不再借 `Unsupported`**。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -32,10 +39,16 @@ use unlucky_core::addon::online::{
|
||||
ResolveRequest,
|
||||
};
|
||||
|
||||
use crate::dto::ContentFailureKind;
|
||||
use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView};
|
||||
|
||||
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 的
|
||||
@@ -98,7 +111,8 @@ fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind {
|
||||
return wanted;
|
||||
}
|
||||
}
|
||||
ContentFailureKind::Unsupported
|
||||
// 只剩「不兼容」这一档:换版本/加载器才有救(M191 起它有自己的大类,不再借 `Unsupported`)
|
||||
ContentFailureKind::Incompatible
|
||||
}
|
||||
|
||||
/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。
|
||||
@@ -106,10 +120,71 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind {
|
||||
match failure.cause {
|
||||
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 {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -192,7 +192,10 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() {
|
||||
Vec::new(),
|
||||
);
|
||||
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 个:P7dR8mSH"));
|
||||
assert!(!failure.detail.contains("下载失败"), "{}", failure.detail);
|
||||
@@ -377,3 +380,6 @@ fn a_required_dependency_without_a_project_id_is_reported() {
|
||||
"{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);
|
||||
}
|
||||
Reference in new issue
Block a user