feat(content): 安装失败的两档分组与「只重试这些」(M191)
M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()), 但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里: - DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开); InstallProgressView 加 retry: Vec<InstallFailureView>(project + 档位 + core 原话), 档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind); ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。 - presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法, host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」—— 调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。 install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。 - 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮; 判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。 - i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。 守门(每条都做过变异验证,见报告): ① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]); ② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红; ③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。 数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。
This commit is contained in:
1 parent
5505ea0f91
commit
5a52f38f35
24 files changed
+1072
-24
No files matched your search
@@ -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,65 @@ 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()` 用的同一份)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstallFailureView {
|
||||
/// 请求它用的项目键(slug 或 id;重试也用它)
|
||||
pub project: String,
|
||||
/// 哪一档
|
||||
pub kind: InstallFailureKind,
|
||||
/// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案)
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl InstallFailureView {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
project: impl Into<String>,
|
||||
kind: InstallFailureKind,
|
||||
detail: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
project: project.into(),
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次安装的进度快照。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InstallProgressView {
|
||||
@@ -77,6 +136,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 +166,7 @@ impl InstallProgressView {
|
||||
eta_secs: 0,
|
||||
message: String::new(),
|
||||
failure_kind: None,
|
||||
retry: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +225,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」,也拿不到可重试的项目。现在逐个文件下、失败记进
|
||||
@@ -40,11 +46,11 @@
|
||||
//! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。
|
||||
//! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
|
||||
resolve, DependencyPlan, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
@@ -78,7 +84,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 +141,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 +157,38 @@ fn run(
|
||||
epoch: u64,
|
||||
) -> Result<String, ContentFailure> {
|
||||
let instance = find_instance(env, &request.instance)?;
|
||||
|
||||
// 「只重试这些」:输入是上一轮那份计划,**不**重新发起根项目安装。
|
||||
// 没有记录时(换了实例 / 进程重启过)回一条失败,而不是退化成「整单重装」。
|
||||
if request.retry {
|
||||
let Some((target_dir, previous)) = plan::remembered(env, &request.instance) else {
|
||||
return Err(local(RETRY_LOST));
|
||||
};
|
||||
progress.version = previous
|
||||
.request
|
||||
.roots
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| request.instance.clone());
|
||||
let present = plan::present_files(&target_dir);
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let dependency_plan = plan::retry_plan(&previous, present, |retry| {
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
block_on(resolve(&client, retry))
|
||||
});
|
||||
return collect(
|
||||
env,
|
||||
request,
|
||||
dependency_plan,
|
||||
&target_dir,
|
||||
cancel,
|
||||
progress,
|
||||
tx,
|
||||
epoch,
|
||||
);
|
||||
}
|
||||
|
||||
let game_dir = game_dir_of(env, &instance)?;
|
||||
let dir = request
|
||||
.kind
|
||||
@@ -165,12 +211,41 @@ 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))
|
||||
};
|
||||
collect(
|
||||
env,
|
||||
request,
|
||||
dependency_plan,
|
||||
&target_dir,
|
||||
cancel,
|
||||
progress,
|
||||
tx,
|
||||
epoch,
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析之后**两条入口共用**的尾巴:进度 → 逐个下载 → 收口。
|
||||
///
|
||||
/// 收口的两件事在同一个地方做,是因为它们必须同时发生:
|
||||
///
|
||||
/// | 结果 | 计划 | 可重试名单 |
|
||||
/// |---|---|---|
|
||||
/// | 有失败 | 记下(`plan::remember`,重试的输入) | 填(界面据此画「只重试这 N 个」) |
|
||||
/// | 全成功 | 忘掉(`plan::forget`) | 清空(界面**不渲染**那条入口) |
|
||||
fn collect(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
mut dependency_plan: DependencyPlan,
|
||||
target_dir: &Path,
|
||||
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;
|
||||
@@ -192,11 +267,12 @@ fn run(
|
||||
epoch,
|
||||
});
|
||||
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let fetcher = EngineArtifactFetcher::new(&core_engine);
|
||||
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。
|
||||
let installed = downloads::download_stage(
|
||||
&mut dependency_plan,
|
||||
&target_dir,
|
||||
target_dir,
|
||||
&fetcher,
|
||||
cancel,
|
||||
progress,
|
||||
@@ -207,11 +283,19 @@ fn run(
|
||||
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
|
||||
// 并列出「可只重试这 N 个」。
|
||||
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
|
||||
progress.retry = plan::retry_items(&dependency_plan);
|
||||
plan::remember(env, &request.instance, target_dir, &dependency_plan);
|
||||
return Err(failure);
|
||||
}
|
||||
progress.retry.clear();
|
||||
plan::forget(env, &request.instance);
|
||||
Ok(plan::summary(&dependency_plan, &installed))
|
||||
}
|
||||
|
||||
/// 没有可重试的记录时那句话(**不**退化成「整单重装」)。
|
||||
const RETRY_LOST: &str =
|
||||
"没有可重试的记录:这次失败发生在别的实例上、或进程重启过。请重新装入那一次。";
|
||||
|
||||
/// 完成态:`files_done` 对齐 `files_total`。
|
||||
///
|
||||
/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态
|
||||
|
||||
@@ -17,23 +17,29 @@
|
||||
//! 所以这里既不合并计数也不合并文案;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()`,于是重试只请求失败的那几个项目,**不是**重新发起根项目安装。
|
||||
//!
|
||||
//! # 不兼容有自己的一档(M191 起)
|
||||
//!
|
||||
//! M186 把它映成 `Unsupported`(当时 DTO 没有这一档,与「缺密钥」共用),于是界面没法单独说
|
||||
//! 「换个版本/加载器」。M191 给 `ContentFailureKind` 加了 `Incompatible`,[`kind_of`] 直接映射,
|
||||
//! **不再借 `Unsupported`**。
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile,
|
||||
ResolveRequest,
|
||||
};
|
||||
|
||||
use crate::dto::ContentFailureKind;
|
||||
use crate::dto::{ContentFailureKind, InstallFailureKind, InstallFailureView};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::sources::failure_of;
|
||||
|
||||
/// 扫目标目录(不递归)里已存在的文件名。
|
||||
@@ -98,7 +104,8 @@ fn overall_kind(plan: &DependencyPlan) -> ContentFailureKind {
|
||||
return wanted;
|
||||
}
|
||||
}
|
||||
ContentFailureKind::Unsupported
|
||||
// 只剩「不兼容」这一档:换版本/加载器才有救(M191 起它有自己的大类,不再借 `Unsupported`)
|
||||
ContentFailureKind::Incompatible
|
||||
}
|
||||
|
||||
/// 单条失败的 DTO 大类(**复用** `sources::failure_of` 的映射表,不另写一份判据)。
|
||||
@@ -106,10 +113,66 @@ 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) -> Vec<InstallFailureView> {
|
||||
plan.failures
|
||||
.iter()
|
||||
.map(|failure| {
|
||||
InstallFailureView::new(
|
||||
failure.requested_as.clone(),
|
||||
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();
|
||||
@@ -199,6 +262,63 @@ fn unresolved_note(plan: &DependencyPlan) -> Option<String> {
|
||||
))
|
||||
}
|
||||
|
||||
/// 「上一次装入留下的计划」表(M191):`只重试这些` 的输入。
|
||||
///
|
||||
/// ## 为什么要一张静态表(说清楚这笔债)
|
||||
///
|
||||
/// 重试要的输入是**上一轮那份 `DependencyPlan`**(`retry_request()` 是它的方法)。它现在活在
|
||||
/// `CoreHost`(`host/dispatch.rs`)之外,因为那个文件不在本 mission 的 scope;与
|
||||
/// `addon/pack.rs` 的 `PACK_STATE` 同一手法:每 host 一格(键 = 数据根),于是测试里两个 host
|
||||
/// 互不干扰。`host/mod.rs` 一旦进 scope,把这张表搬成一个字段即可(这表就是那个字段)。
|
||||
///
|
||||
/// 键里带 `instance`:同一台机器上往两个实例装过东西,重试只该重试这个实例那一次的计划。
|
||||
/// 成功之后由 [`forget`] 清掉——「全成功」时不该留下任何可重试的东西。
|
||||
struct Remembered {
|
||||
host: PathBuf,
|
||||
instance: String,
|
||||
/// 落盘目录(重试要按它重新扫 `present`,且**不许**换目录)
|
||||
dir: PathBuf,
|
||||
plan: DependencyPlan,
|
||||
}
|
||||
|
||||
static REMEMBERED: Mutex<Vec<Remembered>> = Mutex::new(Vec::new());
|
||||
|
||||
/// 记下这一轮的计划(**覆盖**同一个 host + 实例的上一份)。
|
||||
pub(super) fn remember(env: &HostEnv, instance: &str, dir: &Path, 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(Remembered {
|
||||
host,
|
||||
instance: instance.to_string(),
|
||||
dir: dir.to_path_buf(),
|
||||
plan: plan.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 忘掉这个实例的计划(装成功 / 没有失败项时调:没有可重试的东西了)。
|
||||
pub(super) 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));
|
||||
}
|
||||
|
||||
/// 这个实例上一次失败的计划(+ 当时的落盘目录);`None` = 没有可重试的记录。
|
||||
pub(super) fn remembered(env: &HostEnv, instance: &str) -> Option<(PathBuf, DependencyPlan)> {
|
||||
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| (item.dir.clone(), item.plan.clone()))
|
||||
}
|
||||
|
||||
/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。
|
||||
///
|
||||
/// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来,
|
||||
|
||||
@@ -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,150 @@
|
||||
//! M191:「只重试这些」的两档名单 + 真重试路径(`super` 的用例按主题分卷,这一卷专管重试)。
|
||||
//!
|
||||
//! 三条判据对应的变异守门在各自的文档里写清了「改了什么这条会红」。
|
||||
|
||||
use super::{failure, node, plan};
|
||||
use crate::dto::{InstallFailureKind, InstallFailureView};
|
||||
use crate::host::addon::plan::{
|
||||
failure_of_plan, forget, remember, remembered, retry_items, retry_plan, retry_request,
|
||||
};
|
||||
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());
|
||||
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).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, &plan);
|
||||
let (saved_dir, saved) = remembered(&env, "survival").expect("记住了这一次");
|
||||
assert_eq!(saved_dir, dir);
|
||||
assert_eq!(saved.failures.len(), 2);
|
||||
// 别的实例不受影响(同一台机器上装过两个实例)
|
||||
assert!(remembered(&env, "modded").is_none());
|
||||
|
||||
// 重试请求从「记住的计划」里取:仍然只含失败项
|
||||
let request = retry_request(&saved, 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);
|
||||
let request = retry_request(&plan, Vec::new());
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item.project.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
request.roots
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user