fix(addon): 复审 r1 —— 失败去重、null 载荷、完成态进度、顺序守门、下载失败归并
- P2-1 失败分支不再「直接 continue」:`fail()` 记一条失败(`record_failure` 按 `requested_as` 去重)并把请求键写进 `alias`,后续同键走现成的 `fold_parents` 折父边 ⇒ 同一项目只取一次版本、失败只一条、重试名单不重复; - P2-2 `RawDependency.dependency_type` 改 `Option<String>`:`#[serde(default)]` 兜不住 `null`,以前一份 `"dependency_type": null` 就让整份 `/version` 报 Malformed、整个项目装不上; 现在 null/未知一律 `Unknown`(不递归); - P2-3 完成态用 `finish()` 把 `files_done` 对齐 `files_total`(曾写死 `1`,装 3 个停在 1/3); 解析前 `files_total` 归 0(未知态),不再假装「已知 1 个文件」; - P2-4 新增 `tests/order.rs`:跨根的层内顺序 + 多根正序/逆序对照,删 `layer.sort_by` 会红; 另加 `required_project_ids` 自己的排序去重契约用例(`tests/regressions.rs`); - P2-5 下载阶段失败记进同一套两档结构(`Unreachable` + 叶子成因)并继续下一个文件: 已下好的不回滚、失败项进可重试名单、文案保留「已装入 N 个」的上下文;`retry_request` 自动覆盖它们; - nit① 在 `plan.rs` 写清「不兼容」为什么落在 DTO 的 `Unsupported`(不是笔误,是缺档); nit② 新增 `DependencyPlan::unresolved_required`(只有版本 id 的必备前置不再静默忽略); nit③ 补「同 mod 另一个版本在目录里仍要下载」的负向用例。
This commit is contained in:
1 parent
011d525e8d
commit
aa33210bb6
12 files changed
+686
-77
No files matched your search
@@ -21,6 +21,10 @@
|
||||
//! 列成「可只重试这 N 个」。**这不是自动重试**:重试要用户(或将来 UI 的按钮)再发一次,
|
||||
//! `DependencyPlan::retry_request()` 已经把请求备好了。
|
||||
//!
|
||||
//! 下载阶段的失败**也进同一套名单**(M186 r1):以前 `download_verified` 一失败就整单中断,
|
||||
//! 用户既看不到「主文件已装、缺 fabric-api」,也拿不到可重试的项目。现在逐个文件下、失败记进
|
||||
//! `DependencyPlan::record_failure`(`Unreachable` + 叶子成因),已下好的不回滚,最后一起收口。
|
||||
//!
|
||||
//! ## 光影与数据包:支持到哪一步(M186 核对过,别写成「都支持」)
|
||||
//!
|
||||
//! | 内容 | 检索 | 装入 | 差在哪 |
|
||||
@@ -40,7 +44,8 @@ use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
download_verified, resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
|
||||
download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport,
|
||||
FailureKind, Modrinth, PlannedNode,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
@@ -53,7 +58,7 @@ use crate::dto::{
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::{plan, sources::failure_of};
|
||||
use super::plan;
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
@@ -67,7 +72,8 @@ pub(super) fn install(
|
||||
version: request.title.clone(),
|
||||
instance: request.instance.clone(),
|
||||
loader: request.loader.label().to_string(),
|
||||
files_total: 1,
|
||||
// 文件数要等解析完才知道(含前置),这里留 0 = 未知(面板画不确定态)。
|
||||
files_total: 0,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
|
||||
@@ -89,8 +95,7 @@ pub(super) fn install(
|
||||
});
|
||||
match run(env, request, cancel, &mut progress, tx, epoch) {
|
||||
Ok(done) => {
|
||||
progress.stage = InstallStage::Done;
|
||||
progress.files_done = 1;
|
||||
finish(&mut progress);
|
||||
progress.message = done;
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
@@ -161,7 +166,7 @@ fn run(
|
||||
plan::present_files(&target_dir),
|
||||
);
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let dependency_plan = {
|
||||
let mut dependency_plan = {
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
block_on(resolve(&client, &resolve_request))
|
||||
@@ -189,11 +194,13 @@ fn run(
|
||||
});
|
||||
|
||||
let fetcher = EngineArtifactFetcher::new(&core_engine);
|
||||
for node in &dependency_plan.nodes {
|
||||
let mut installed: Vec<PlannedNode> = Vec::new();
|
||||
for index in 0..dependency_plan.nodes.len() {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载中途)"));
|
||||
}
|
||||
progress.message = node_label(node);
|
||||
let node = dependency_plan.nodes[index].clone();
|
||||
progress.message = node_label(&node);
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
@@ -201,15 +208,36 @@ fn run(
|
||||
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
|
||||
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
|
||||
let dest = target_dir.join(safe_file_name(&node.file.filename));
|
||||
block_on(download_verified(
|
||||
let outcome = block_on(download_verified(
|
||||
&fetcher,
|
||||
&node.file.url,
|
||||
node.file.sha1(),
|
||||
node.file.sha512(),
|
||||
(node.file.size > 0).then_some(node.file.size),
|
||||
&dest,
|
||||
))
|
||||
.map_err(failure_of)?;
|
||||
));
|
||||
match outcome {
|
||||
Ok(_) => installed.push(node),
|
||||
Err(error) => {
|
||||
// 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因):
|
||||
// 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。
|
||||
// 以前这里 `.map_err(...)?` 直接中断,用户看不到「主文件已装、缺 fabric-api」。
|
||||
dependency_plan.record_failure(DependencyFailure {
|
||||
requested_as: node.requested_as.clone(),
|
||||
required_by: node.required_by.clone(),
|
||||
depth: node.depth,
|
||||
kind: FailureKind::Unreachable,
|
||||
cause: Some(error.kind()),
|
||||
message: format!(
|
||||
"下载 {} 失败({}):{}",
|
||||
node.file.filename,
|
||||
error.kind().as_str(),
|
||||
error.message()
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
progress.files_done += 1;
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
@@ -217,11 +245,21 @@ fn run(
|
||||
});
|
||||
}
|
||||
|
||||
// 解析阶段的失败在这里收口:两档分成两句话(不合并成「下载失败」),并列出可只重试的项目。
|
||||
if let Some(failure) = plan::failure_of_plan(&dependency_plan) {
|
||||
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
|
||||
// 并列出「可只重试这 N 个」。
|
||||
if let Some(failure) = plan::failure_of_plan(&dependency_plan, installed.len()) {
|
||||
return Err(failure);
|
||||
}
|
||||
Ok(plan::summary(&dependency_plan))
|
||||
Ok(plan::summary(&dependency_plan, &installed))
|
||||
}
|
||||
|
||||
/// 完成态:`files_done` 对齐 `files_total`。
|
||||
///
|
||||
/// 别在这里另写数字:以前写死 `1`,装 3 个文件后终态是 `1/3`,下载页只要「已知」就画确定态
|
||||
/// 进度条 ⇒ 装完了条还停在 1/3(全部跳过时更糟,`1/0`)。
|
||||
pub(super) fn finish(progress: &mut InstallProgressView) {
|
||||
progress.stage = InstallStage::Done;
|
||||
progress.files_done = progress.files_total;
|
||||
}
|
||||
|
||||
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。
|
||||
|
||||
@@ -16,11 +16,20 @@
|
||||
//! 「不兼容所选版本/加载器」与「取不到(网络 / 项目不存在)」是两句话、两种用户动作,
|
||||
//! 所以这里既不合并计数也不合并文案;DTO 大类按「用户接下来该做什么」选:离线优先说
|
||||
//! 检查网络,其次查无此项,只有**全是**不兼容时才说「这条路走不通」(换版本/加载器才有救)。
|
||||
//!
|
||||
//! # DTO 没有「不兼容」这一档,所以它落在 `Unsupported`(说清楚,别当它是笔误)
|
||||
//!
|
||||
//! `ContentFailureKind` 的五档里没有「站点有这个项目、但所选版本/加载器下没有条目」这一档
|
||||
//! (M186 已提 finding)。它**不是** `NotFound`(那是「站点说没这个项目」)也**不是** `Offline`,
|
||||
//! 最贴的只有 `Unsupported`(「这条路走不通」)——所以根项目「没有匹配版本」的 DTO 大类由
|
||||
//! M162 的 `NotFound` 改成了 `Unsupported`,语义矫正而非回归;文案里的
|
||||
//! 「不兼容所选版本/加载器」才是给用户看的那句话。UI 当前只按 `is_offline()` 分流,无可见差异。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, FailureKind, OnlineError, PresentFile, ResolveRequest,
|
||||
DependencyFailure, DependencyPlan, FailureKind, OnlineError, PlannedNode, PresentFile,
|
||||
ResolveRequest,
|
||||
};
|
||||
|
||||
use crate::dto::ContentFailureKind;
|
||||
@@ -59,13 +68,19 @@ pub(super) fn request(
|
||||
}
|
||||
|
||||
/// 计划里有失败吗 ⇒ 一条带大类的失败(`None` = 干净)。
|
||||
pub(super) fn failure_of_plan(plan: &DependencyPlan) -> Option<crate::dto::ContentFailure> {
|
||||
///
|
||||
/// `installed` = **真落盘的**文件数(下载失败的不算):失败文案里要能说清「主文件已装、
|
||||
/// 缺的是谁的前置」,所以不能拿 `plan.nodes.len()` 冒充。
|
||||
pub(super) fn failure_of_plan(
|
||||
plan: &DependencyPlan,
|
||||
installed: usize,
|
||||
) -> Option<crate::dto::ContentFailure> {
|
||||
if plan.is_clean() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::dto::ContentFailure::new(
|
||||
overall_kind(plan),
|
||||
report(plan),
|
||||
report(plan, installed),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -96,7 +111,7 @@ fn kind_of(failure: &DependencyFailure) -> ContentFailureKind {
|
||||
}
|
||||
|
||||
/// 失败清单(两档各起一段,各自的条数分开数)。
|
||||
fn report(plan: &DependencyPlan) -> String {
|
||||
fn report(plan: &DependencyPlan, installed: usize) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let incompatible: Vec<String> = plan
|
||||
.failures_of(FailureKind::Incompatible)
|
||||
@@ -120,12 +135,15 @@ fn report(plan: &DependencyPlan) -> String {
|
||||
unreachable.join(";")
|
||||
));
|
||||
}
|
||||
if !plan.nodes.is_empty() {
|
||||
lines.push(format!("已装入 {} 个文件", plan.nodes.len()));
|
||||
if installed > 0 {
|
||||
lines.push(format!("已装入 {installed} 个文件(已下好的不回滚)"));
|
||||
}
|
||||
if !plan.skipped.is_empty() {
|
||||
lines.push(format!("跳过 {} 个已存在的文件", plan.skipped.len()));
|
||||
}
|
||||
if let Some(note) = unresolved_note(plan) {
|
||||
lines.push(note);
|
||||
}
|
||||
if !plan.truncated.is_empty() {
|
||||
lines.push(format!(
|
||||
"依赖层数超过上限,未展开:{}",
|
||||
@@ -133,11 +151,7 @@ fn report(plan: &DependencyPlan) -> String {
|
||||
));
|
||||
}
|
||||
// 可重试名单:只列失败项(重试时也只重解这些,不重下已装好的)
|
||||
let retry: Vec<&str> = plan
|
||||
.failures
|
||||
.iter()
|
||||
.map(|failure| failure.requested_as.as_str())
|
||||
.collect();
|
||||
let retry = plan.retry_request().roots;
|
||||
if !retry.is_empty() {
|
||||
lines.push(format!(
|
||||
"可只重试这 {} 个:{}",
|
||||
@@ -161,17 +175,43 @@ fn describe(failure: &DependencyFailure) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 「有 required 但没有项目 id」那一档的提示(解析不了,但也不是没事发生)。
|
||||
fn unresolved_note(plan: &DependencyPlan) -> Option<String> {
|
||||
if plan.unresolved_required.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let items: Vec<String> = plan
|
||||
.unresolved_required
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let what = item
|
||||
.version_id
|
||||
.clone()
|
||||
.or_else(|| item.file_name.clone())
|
||||
.unwrap_or_else(|| "未给版本".to_string());
|
||||
format!("{}(被 {} 要求)", what, item.parent)
|
||||
})
|
||||
.collect();
|
||||
Some(format!(
|
||||
"有 {} 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:{}",
|
||||
items.len(),
|
||||
items.join(";")
|
||||
))
|
||||
}
|
||||
|
||||
/// 全部装成之后的落盘描述(含跳过与前置的条数、「为什么装」的父项目)。
|
||||
pub(super) fn summary(plan: &DependencyPlan) -> String {
|
||||
if plan.nodes.is_empty() && plan.skipped.is_empty() {
|
||||
///
|
||||
/// `installed` 是**真落盘**的那些(下载失败的不在里面),所以条数与文件名都从它来,
|
||||
/// 不从 `plan.nodes` 来——否则「装成了 N 个」会把下载失败的也数进去。
|
||||
pub(super) fn summary(plan: &DependencyPlan, installed: &[PlannedNode]) -> String {
|
||||
if installed.is_empty() && plan.skipped.is_empty() && plan.unresolved_required.is_empty() {
|
||||
return "没有要装的文件".to_string();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !plan.nodes.is_empty() {
|
||||
let dependencies = plan.nodes.iter().filter(|node| !node.is_root()).count();
|
||||
if !installed.is_empty() {
|
||||
let dependencies = installed.iter().filter(|node| !node.is_root()).count();
|
||||
// 「为什么装」:前置标出要求它的项目(根是用户自己点的,不标)
|
||||
let names: Vec<String> = plan
|
||||
.nodes
|
||||
let names: Vec<String> = installed
|
||||
.iter()
|
||||
.map(|node| {
|
||||
if node.is_root() {
|
||||
@@ -186,11 +226,11 @@ pub(super) fn summary(plan: &DependencyPlan) -> String {
|
||||
})
|
||||
.collect();
|
||||
let head = if dependencies == 0 {
|
||||
format!("装了 {} 个文件", plan.nodes.len())
|
||||
format!("装了 {} 个文件", installed.len())
|
||||
} else {
|
||||
format!(
|
||||
"装了 {} 个文件(含 {} 个前置)",
|
||||
plan.nodes.len(),
|
||||
installed.len(),
|
||||
dependencies
|
||||
)
|
||||
};
|
||||
@@ -208,6 +248,9 @@ pub(super) fn summary(plan: &DependencyPlan) -> String {
|
||||
names.join("、")
|
||||
));
|
||||
}
|
||||
if let Some(note) = unresolved_note(plan) {
|
||||
parts.push(note);
|
||||
}
|
||||
if !plan.truncated.is_empty() {
|
||||
parts.push(format!(
|
||||
"依赖层数超过上限,未展开:{}",
|
||||
|
||||
@@ -118,10 +118,12 @@ fn core_errors_map_to_dto_failure_kinds() {
|
||||
|
||||
// ------------------------------------------------ M186:前置解析的两档失败与可重试名单
|
||||
|
||||
use super::install::finish;
|
||||
use super::plan::{failure_of_plan, summary};
|
||||
use crate::dto::{InstallProgressView, InstallStage};
|
||||
use unlucky_core::addon::online::{
|
||||
DependencyFailure, DependencyPlan, FailureKind, PlannedNode, ResolveRequest, SkipReason,
|
||||
SkippedNode, VersionFile,
|
||||
SkippedNode, UnresolvedRequired, VersionFile,
|
||||
};
|
||||
|
||||
fn node(project: &str, file: &str, depth: u8, required_by: &[&str]) -> PlannedNode {
|
||||
@@ -172,6 +174,7 @@ fn plan(
|
||||
skipped,
|
||||
failures,
|
||||
truncated: Vec::new(),
|
||||
unresolved_required: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +191,7 @@ fn an_incompatible_only_plan_is_not_reported_as_a_download_failure() {
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan).expect("有失败就该给一条");
|
||||
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::Unsupported);
|
||||
assert!(failure.detail.contains("不兼容所选版本/加载器 1 个"));
|
||||
assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH"));
|
||||
@@ -221,7 +224,7 @@ fn a_mixed_plan_keeps_both_buckets_and_picks_the_actionable_kind() {
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan).expect("有失败就该给一条");
|
||||
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::NotFound);
|
||||
assert!(failure.detail.contains("不兼容所选版本/加载器 1 个"));
|
||||
assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个"));
|
||||
@@ -249,7 +252,7 @@ fn an_offline_jump_wins_over_everything_else() {
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan).expect("有失败就该给一条");
|
||||
let failure = failure_of_plan(&plan, 0).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::Offline);
|
||||
assert!(failure.is_offline());
|
||||
}
|
||||
@@ -272,8 +275,9 @@ fn the_success_message_counts_dependencies_and_skipped_files() {
|
||||
reason: SkipReason::AlreadyPresent,
|
||||
}],
|
||||
);
|
||||
assert!(failure_of_plan(&plan).is_none());
|
||||
let message = summary(&plan);
|
||||
assert!(failure_of_plan(&plan, 2).is_none());
|
||||
let installed = plan.nodes.clone();
|
||||
let message = summary(&plan, &installed);
|
||||
assert!(
|
||||
message.contains("装了 2 个文件(含 1 个前置)"),
|
||||
"{message}"
|
||||
@@ -302,3 +306,74 @@ fn present_files_lists_only_files_in_the_target_directory() {
|
||||
assert_eq!(names, ["sodium.jar", "yacl.jar.disabled"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ------------------------------------------------ M186 r1:完成态进度、下载失败上下文、无地址前置
|
||||
|
||||
/// 完成态不许把进度倒回 `1/N`(r1 P2-3:这里曾写死 `files_done = 1`)。
|
||||
#[test]
|
||||
fn finishing_aligns_the_terminal_progress_with_the_total() {
|
||||
let mut progress = InstallProgressView {
|
||||
files_total: 3,
|
||||
files_done: 3,
|
||||
stage: InstallStage::Downloading,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
finish(&mut progress);
|
||||
assert_eq!(progress.stage, InstallStage::Done);
|
||||
assert_eq!(progress.files_done, progress.files_total);
|
||||
assert_eq!(progress.ratio(), 1.0, "装完了条必须满");
|
||||
|
||||
// 全部跳过(total = 0)时也不许变成 1/0
|
||||
let mut skipped_all = InstallProgressView {
|
||||
files_total: 0,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
finish(&mut skipped_all);
|
||||
assert_eq!(skipped_all.files_done, 0);
|
||||
assert_eq!(skipped_all.stage, InstallStage::Done);
|
||||
}
|
||||
|
||||
/// 下载阶段的失败也要带上下文:已装几个 + 缺的是谁的前置 + 可只重试谁。
|
||||
#[test]
|
||||
fn a_download_failure_keeps_the_already_installed_context() {
|
||||
let plan = plan(
|
||||
vec![failure(
|
||||
"P7dR8mSH",
|
||||
FailureKind::Unreachable,
|
||||
Some(OnlineErrorKind::NotConnected),
|
||||
"下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
|
||||
)],
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
let failure = failure_of_plan(&plan, 1).expect("有失败就该给一条");
|
||||
assert_eq!(failure.kind, ContentFailureKind::Offline);
|
||||
assert!(
|
||||
failure.detail.contains("已装入 1 个文件(已下好的不回滚)"),
|
||||
"{}",
|
||||
failure.detail
|
||||
);
|
||||
assert!(failure.detail.contains("取不到(网络 / 项目不存在)1 个"));
|
||||
assert!(failure.detail.contains("可只重试这 1 个:P7dR8mSH"));
|
||||
}
|
||||
|
||||
/// 只有版本 id、没有项目 id 的必备前置:既装不了,也不许静默吞掉。
|
||||
#[test]
|
||||
fn a_required_dependency_without_a_project_id_is_reported() {
|
||||
let mut plan = plan(
|
||||
Vec::new(),
|
||||
vec![node("AANobbMI", "sodium.jar", 0, &[])],
|
||||
Vec::new(),
|
||||
);
|
||||
plan.unresolved_required = vec![UnresolvedRequired {
|
||||
parent: "AANobbMI".to_string(),
|
||||
version_id: Some("ver-embedded".to_string()),
|
||||
file_name: Some("embedded-lib.jar".to_string()),
|
||||
}];
|
||||
let installed = plan.nodes.clone();
|
||||
let message = summary(&plan, &installed);
|
||||
assert!(
|
||||
message.contains("有 1 条必备前置只给了版本/文件名、没有项目 id,无法自动解析:ver-embedded(被 AANobbMI 要求)"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user