fix(m162-r1): 叶子成因穿出引擎 + 安装路径切 core 在线源 + 详情迟到结果闸门
P1-1(p1-1items 的根因): - download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与 `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有 Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」 取最可诊断的那个(**不从 suppressed 文本猜状态码**)。 - download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。 - online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是 NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。 - import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开; `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。 - import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN, Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此 只会在真断网时出现。 P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用 `core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`; `HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`, `InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。 P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回 `content_install_request`,详情方法只留自己那一行。 P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的 项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到 结果清掉。 顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过; 两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的 ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。 用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条 (AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、 单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」 (共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
This commit is contained in:
1 parent
81278f55af
commit
b394316df4
30 files changed
+658
-172
No files matched your search
@@ -166,14 +166,10 @@ impl App {
|
||||
.is_some_and(|results| results.curseforge)
|
||||
}
|
||||
|
||||
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
|
||||
///
|
||||
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
|
||||
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
|
||||
///
|
||||
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
|
||||
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
|
||||
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
|
||||
///
|
||||
/// 详情是**独立通道**:检索结果里只有 facets(支持哪些版本/加载器),拿不到「有哪些版本
|
||||
/// 可以装」;跟着检索一起拉等于每页多 N 次请求,所以只在选中时发一条。
|
||||
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
@@ -190,6 +186,13 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
|
||||
///
|
||||
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
|
||||
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
|
||||
///
|
||||
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
|
||||
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
|
||||
fn content_install_request(&self) -> Option<ContentInstallRequest> {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
|
||||
@@ -236,23 +236,26 @@ fn versions_come_from_the_detail_channel_of_the_selected_project() {
|
||||
assert!(model.available_versions.is_empty());
|
||||
assert!(model.versions_note.is_some());
|
||||
|
||||
state.content.apply_detail(ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
title: "Sodium".to_string(),
|
||||
description: "fast".to_string(),
|
||||
body: String::new(),
|
||||
versions: vec![ContentVersionView {
|
||||
id: "v1".to_string(),
|
||||
name: "Sodium 0.6.0".to_string(),
|
||||
number: "mc1.20.1-0.6.0".to_string(),
|
||||
kind: "release".to_string(),
|
||||
game_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
published: "2026-02-01T00:00:00Z".to_string(),
|
||||
file_name: Some("sodium.jar".to_string()),
|
||||
file_size: Some(2048),
|
||||
}],
|
||||
});
|
||||
state.content.apply_detail(
|
||||
"p1",
|
||||
ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
title: "Sodium".to_string(),
|
||||
description: "fast".to_string(),
|
||||
body: String::new(),
|
||||
versions: vec![ContentVersionView {
|
||||
id: "v1".to_string(),
|
||||
name: "Sodium 0.6.0".to_string(),
|
||||
number: "mc1.20.1-0.6.0".to_string(),
|
||||
kind: "release".to_string(),
|
||||
game_versions: vec!["1.20.1".to_string()],
|
||||
loaders: vec!["fabric".to_string()],
|
||||
published: "2026-02-01T00:00:00Z".to_string(),
|
||||
file_name: Some("sodium.jar".to_string()),
|
||||
file_size: Some(2048),
|
||||
}],
|
||||
},
|
||||
);
|
||||
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
|
||||
assert_eq!(model.available_versions.len(), 1);
|
||||
assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0");
|
||||
|
||||
@@ -98,6 +98,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
|
||||
bytes_total: 0,
|
||||
speed_bps: 0,
|
||||
eta_secs: 0,
|
||||
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
|
||||
failure_kind: None,
|
||||
message: progress.current.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
|
||||
speed_bps: if failed { 0 } else { 1_258_291 },
|
||||
eta_secs: if failed { 0 } else { 75 },
|
||||
message: String::new(),
|
||||
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
|
||||
failure_kind: None,
|
||||
},
|
||||
catalog,
|
||||
)
|
||||
|
||||
@@ -82,7 +82,9 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan)
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
if error.kind() == FetchErrorKind::Network {
|
||||
// 只有**真正的「没联网」**才提前收工:404(这条不在 Modrinth 上,或托管在
|
||||
// CF CDN)与 5xx 都只影响这一条,后面那些仍然可能补得出来。
|
||||
if error.kind() == FetchErrorKind::Offline {
|
||||
state.offline = true;
|
||||
}
|
||||
state.unfilled += 1;
|
||||
|
||||
@@ -176,9 +176,10 @@ impl From<FetchError> for ImportError {
|
||||
let kind = match error.kind() {
|
||||
FetchErrorKind::Integrity => ImportErrorKind::Integrity,
|
||||
FetchErrorKind::Io => ImportErrorKind::Pack(PackErrorKind::Io),
|
||||
FetchErrorKind::NotFound | FetchErrorKind::Network | FetchErrorKind::Unsupported => {
|
||||
ImportErrorKind::Fetch
|
||||
}
|
||||
FetchErrorKind::NotFound
|
||||
| FetchErrorKind::Network
|
||||
| FetchErrorKind::Offline
|
||||
| FetchErrorKind::Unsupported => ImportErrorKind::Fetch,
|
||||
};
|
||||
Self {
|
||||
kind,
|
||||
|
||||
@@ -44,8 +44,11 @@ use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind};
|
||||
pub enum FetchErrorKind {
|
||||
/// 源上确实没有(404 / 文件被删)。
|
||||
NotFound,
|
||||
/// 网络侧失败(超时、连不上、所有候选源都失败)。
|
||||
/// 网络侧失败(超时、连不上、站点 5xx 退避后仍失败)。
|
||||
Network,
|
||||
/// **没联网**:连不上任何候选源(与 `Network` 分开,是因为「补全」见到它要立刻停,
|
||||
/// 而见到 404 / 5xx 不该停 —— 下一条可能补得出来)。
|
||||
Offline,
|
||||
/// 本地 IO(写盘失败、磁盘满)。
|
||||
Io,
|
||||
/// 内容与声明的摘要不符。
|
||||
@@ -61,6 +64,7 @@ impl FetchErrorKind {
|
||||
match self {
|
||||
Self::NotFound => "fetch_not_found",
|
||||
Self::Network => "fetch_network",
|
||||
Self::Offline => "fetch_offline",
|
||||
Self::Io => "fetch_io",
|
||||
Self::Integrity => "fetch_integrity",
|
||||
Self::Unsupported => "fetch_unsupported",
|
||||
@@ -70,7 +74,10 @@ impl FetchErrorKind {
|
||||
/// 是否属于"某个文件搞不到"(可容忍,实例照装)。
|
||||
#[must_use]
|
||||
pub const fn is_tolerable(self) -> bool {
|
||||
matches!(self, Self::NotFound | Self::Network | Self::Unsupported)
|
||||
matches!(
|
||||
self,
|
||||
Self::NotFound | Self::Network | Self::Offline | Self::Unsupported
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,14 +133,15 @@ impl std::error::Error for FetchError {}
|
||||
|
||||
impl From<DownloadError> for FetchError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
// 用**叶子成因**(`effective_kind`):候选源循环结束时只有一条 `AllSourcesFailed`,
|
||||
// 直接按它判定会把「这个文件不存在」也算成「网络失败」。
|
||||
let kind = match error.effective_kind() {
|
||||
DownloadErrorKind::NotFound => FetchErrorKind::NotFound,
|
||||
DownloadErrorKind::Integrity => FetchErrorKind::Integrity,
|
||||
DownloadErrorKind::Io => FetchErrorKind::Io,
|
||||
DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported,
|
||||
DownloadErrorKind::Timeout | DownloadErrorKind::Network => FetchErrorKind::Offline,
|
||||
DownloadErrorKind::ServerError
|
||||
| DownloadErrorKind::Timeout
|
||||
| DownloadErrorKind::Network
|
||||
| DownloadErrorKind::RangeUnsupported
|
||||
| DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network,
|
||||
};
|
||||
@@ -148,7 +156,9 @@ impl From<OnlineError> for FetchError {
|
||||
OnlineErrorKind::Integrity | OnlineErrorKind::Malformed => FetchErrorKind::Integrity,
|
||||
OnlineErrorKind::Io => FetchErrorKind::Io,
|
||||
OnlineErrorKind::Unsupported => FetchErrorKind::Unsupported,
|
||||
OnlineErrorKind::NotConnected | OnlineErrorKind::Server => FetchErrorKind::Network,
|
||||
// 「没联网」要能被「补全立刻停」认出来;站点 5xx 只是这一条补不出。
|
||||
OnlineErrorKind::NotConnected => FetchErrorKind::Offline,
|
||||
OnlineErrorKind::Server => FetchErrorKind::Network,
|
||||
};
|
||||
Self::new(kind, error.message().to_string())
|
||||
}
|
||||
|
||||
@@ -122,8 +122,17 @@ pub async fn download_verified(
|
||||
if url.trim().is_empty() {
|
||||
return Err(OnlineError::unsupported("没有可用下载地址"));
|
||||
}
|
||||
let sha1 = check_hash(sha1, HashAlgorithm::Sha1)?;
|
||||
let sha512 = check_hash(sha512, HashAlgorithm::Sha512)?;
|
||||
// 两档摘要**各自**过形状检查:坏掉的那一档丢掉,用另一档校验(mrpack 的权威摘要是
|
||||
// sha512,CLI/整合包都可能给出一个人写坏的 sha1——为此整条不装是过分惩罚)。
|
||||
// 但如果**声明过**摘要却一档都用不上,就必须失败:那是「没有任何校验」的伪装。
|
||||
let declared = sha1.is_some() || sha512.is_some();
|
||||
let sha1 = usable_hash(sha1, HashAlgorithm::Sha1);
|
||||
let sha512 = usable_hash(sha512, HashAlgorithm::Sha512);
|
||||
if declared && sha1.is_none() && sha512.is_none() {
|
||||
return Err(OnlineError::unsupported(
|
||||
"声明的 sha1/sha512 都不是合法摘要,无法校验(拒绝在「没有校验」的情况下落盘)",
|
||||
));
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| {
|
||||
OnlineError::new(
|
||||
@@ -165,7 +174,14 @@ pub async fn download_verified(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let digest = digest_file(dest)?;
|
||||
let digest = match digest_file(dest) {
|
||||
Ok(digest) => digest,
|
||||
Err(error) => {
|
||||
// 与其它失败路径一致:读不出来就不许把文件留在盘上。
|
||||
remove_quiet(dest);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(expected) = sha1 {
|
||||
if !equals_hex(expected, &digest.sha1) {
|
||||
last = Some(OnlineError::integrity(format!(
|
||||
@@ -239,19 +255,15 @@ pub fn digest_file(path: &Path) -> Result<Digest, OnlineError> {
|
||||
|
||||
/// 摘要形状合法吗(长度 + 十六进制)。
|
||||
///
|
||||
/// 返回的是调用方原来那段文本(不复制、不归一),大小写不敏感的比较交给 [`equals_hex`]。
|
||||
fn check_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Result<Option<&str>, OnlineError> {
|
||||
match raw.map(str::trim) {
|
||||
None => Ok(None),
|
||||
Some("") => Ok(None),
|
||||
Some(hex) if algorithm.accepts(hex) => Ok(Some(hex)),
|
||||
Some(hex) => Err(OnlineError::unsupported(format!(
|
||||
"声明的 {} 不是合法摘要:{:?}(要求 {} 位十六进制)",
|
||||
algorithm.as_str(),
|
||||
hex,
|
||||
algorithm.hex_len()
|
||||
))),
|
||||
}
|
||||
/// 这一档摘要能用吗:形状合法就原样返回(不复制、不归一,大小写不敏感比较交给
|
||||
/// [`equals_hex`]),形状不合法就当「没给」。
|
||||
///
|
||||
/// 为什么丢掉而不是报错:`sha1` 与 `sha512` 是同一个文件的两档校验,**任一**都能证明内容;
|
||||
/// 一档写坏不该让整条装不上(r1 顺手项)。两档都不可用时由 `download_verified` 统一拒绝。
|
||||
fn usable_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Option<&str> {
|
||||
raw.map(str::trim)
|
||||
.filter(|hex| !hex.is_empty())
|
||||
.filter(|hex| algorithm.accepts(hex))
|
||||
}
|
||||
|
||||
/// 十六进制比较(大小写不敏感:声明侧可能大写)。
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。
|
||||
//!
|
||||
//! 这一族的价值全在「经过真的 `From<DownloadError>`」:直接给 `OnlineError` 造一个
|
||||
//! `NotFound` 什么也证明不了——本轮修的是**引擎把叶子成因折叠掉**之后上层的映射,
|
||||
//! 所以输入必须是引擎真会产出的那种错误(`AllSourcesFailed` + `leaf`)。
|
||||
|
||||
use crate::addon::online::{OnlineError, OnlineErrorKind};
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
|
||||
#[test]
|
||||
fn an_all_sources_failed_with_a_404_leaf_is_not_found() {
|
||||
// 引擎在「所有候选源都回 404」时的真实形状(`download::bytes` 的收尾)。
|
||||
let error = DownloadError::new(
|
||||
DownloadErrorKind::AllSourcesFailed,
|
||||
"所有候选源都取不到文本资源:https://api.modrinth.com/v2/version_file/...",
|
||||
)
|
||||
.with_suppressed(vec!["official: HTTP 404".to_string()])
|
||||
.with_leaf(DownloadErrorKind::NotFound);
|
||||
let online: OnlineError = error.into();
|
||||
assert_eq!(
|
||||
online.kind(),
|
||||
OnlineErrorKind::NotFound,
|
||||
"项目不存在 / id 拼错必须报 NotFound,不能画成「未联网」"
|
||||
);
|
||||
assert!(!online.is_offline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_sources_failed_without_a_leaf_stays_conservatively_offline() {
|
||||
let online: OnlineError =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部候选源都失败").into();
|
||||
assert_eq!(online.kind(), OnlineErrorKind::NotConnected);
|
||||
assert!(online.is_offline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_kinds_map_one_to_one() {
|
||||
let cases = [
|
||||
(DownloadErrorKind::NotFound, OnlineErrorKind::NotFound),
|
||||
(DownloadErrorKind::ServerError, OnlineErrorKind::Server),
|
||||
(DownloadErrorKind::Timeout, OnlineErrorKind::NotConnected),
|
||||
(DownloadErrorKind::Network, OnlineErrorKind::NotConnected),
|
||||
(DownloadErrorKind::Integrity, OnlineErrorKind::Integrity),
|
||||
(DownloadErrorKind::Io, OnlineErrorKind::Io),
|
||||
(
|
||||
DownloadErrorKind::InvalidRequest,
|
||||
OnlineErrorKind::Unsupported,
|
||||
),
|
||||
(
|
||||
DownloadErrorKind::RangeUnsupported,
|
||||
OnlineErrorKind::Malformed,
|
||||
),
|
||||
];
|
||||
for (leaf, expected) in cases {
|
||||
let error =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败").with_leaf(leaf);
|
||||
let online: OnlineError = error.into();
|
||||
assert_eq!(online.kind(), expected, "叶子 {} 映射错了", leaf.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_source_failure_uses_its_own_kind() {
|
||||
// 单源直失败(没有 AllSourcesFailed 这一层):不该看 leaf,本来就没有。
|
||||
let online: OnlineError = DownloadError::new(DownloadErrorKind::NotFound, "HTTP 404").into();
|
||||
assert_eq!(online.kind(), OnlineErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_404_leaf_is_not_treated_as_offline_by_the_importer_too() {
|
||||
// 导入侧(补全)用的是另一套分类:这里只钉「404 与没联网不同类」。
|
||||
use crate::addon::import::FetchErrorKind;
|
||||
let not_found: crate::addon::import::FetchError =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
|
||||
.with_leaf(DownloadErrorKind::NotFound)
|
||||
.into();
|
||||
assert_eq!(not_found.kind(), FetchErrorKind::NotFound);
|
||||
assert!(not_found.kind().is_tolerable());
|
||||
|
||||
let offline: crate::addon::import::FetchError =
|
||||
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
|
||||
.with_leaf(DownloadErrorKind::Network)
|
||||
.into();
|
||||
assert_eq!(offline.kind(), FetchErrorKind::Offline);
|
||||
assert!(
|
||||
offline.kind().is_tolerable(),
|
||||
"没联网仍然是「这条文件搞不到」,实例照装"
|
||||
);
|
||||
}
|
||||
@@ -155,6 +155,26 @@ fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() {
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_sha1_is_dropped_and_the_sha512_still_guards_the_file() {
|
||||
// r1 顺手项:sha1 形状写坏不该让整条装不上——sha512 才是 mrpack 的权威摘要。
|
||||
let root = TempRoot::new("online-bad-sha1-good-sha512");
|
||||
let dest = root.join("sodium.jar");
|
||||
let fetcher = FakeArtifact::new(BYTES);
|
||||
let (_, sha512) = spec();
|
||||
let done = block_on(download_verified(
|
||||
&fetcher,
|
||||
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
|
||||
Some("not-a-sha1-at-all"),
|
||||
Some(&sha512),
|
||||
Some(BYTES.len() as u64),
|
||||
&dest,
|
||||
))
|
||||
.expect("sha512 合法就该能校验并装下");
|
||||
assert_eq!(done.digest.sha512, sha512);
|
||||
assert!(dest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_url_is_unsupported() {
|
||||
let root = TempRoot::new("online-no-url");
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! M162 报告的「没验的部分」里。
|
||||
|
||||
mod api;
|
||||
mod classify;
|
||||
mod complete;
|
||||
mod download;
|
||||
mod fixtures;
|
||||
|
||||
@@ -139,21 +139,40 @@ impl std::error::Error for OnlineError {}
|
||||
|
||||
/// `DownloadEngine` 的错误 → 本模块的错误分类。
|
||||
///
|
||||
/// **分类口径**:连接类/超时/所有候选源失败都是「没联网」(用户能做的只有检查网络);
|
||||
/// 5xx/429 是「站点那边的问题」,与没网不同,但都值得重试一次。
|
||||
/// # 为什么读 `effective_kind()` 而不是 `kind`
|
||||
///
|
||||
/// 引擎的候选源循环结束时只留下一条 [`DownloadErrorKind::AllSourcesFailed`],404 / 429 / 5xx /
|
||||
/// 超时这些**叶子**成因会被折叠掉(`crates/core/src/download/bytes.rs` 的收尾)。
|
||||
/// 如果直接按 `kind` 判定,`NotFound` / `Server` / `Integrity` 三档在本模块的生产路径上
|
||||
/// **永远不可达**:项目被删、id 拼错、被限速都会画成「未联网」(r1 P1-1 后果 A)。
|
||||
///
|
||||
/// 引擎现在会把汇总出来的叶子 kind 挂在 `DownloadError::leaf` 上,这里按叶子映射;
|
||||
/// 没有叶子(单源直失败 / 手工构造)就按自身 kind。**不从 `suppressed` 文本里猜状态码。**
|
||||
///
|
||||
/// # 分类口径
|
||||
///
|
||||
/// | 引擎侧 | 本模块 | 上层该怎么说 |
|
||||
/// |--------|--------|--------------|
|
||||
/// | `NotFound`(404/410/403,全源都是) | [`OnlineErrorKind::NotFound`] | 「查无此项 / 换个关键词」 |
|
||||
/// | `ServerError`(5xx / 429) | [`OnlineErrorKind::Server`] | 「站点那边出错了」 |
|
||||
/// | `Network` / `Timeout` | [`OnlineErrorKind::NotConnected`] | 「未联网,检查网络」 |
|
||||
/// | `AllSourcesFailed`(没有叶子) | [`OnlineErrorKind::NotConnected`] | 同上(保守) |
|
||||
/// | `Integrity` | [`OnlineErrorKind::Integrity`] | 「内容不对」 |
|
||||
impl From<DownloadError> for OnlineError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
let kind = match error.effective_kind() {
|
||||
DownloadErrorKind::NotFound => OnlineErrorKind::NotFound,
|
||||
DownloadErrorKind::ServerError => OnlineErrorKind::Server,
|
||||
DownloadErrorKind::Timeout
|
||||
| DownloadErrorKind::Network
|
||||
| DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
|
||||
DownloadErrorKind::Io => OnlineErrorKind::Io,
|
||||
DownloadErrorKind::Integrity | DownloadErrorKind::RangeUnsupported => {
|
||||
OnlineErrorKind::Malformed
|
||||
DownloadErrorKind::Timeout | DownloadErrorKind::Network => {
|
||||
OnlineErrorKind::NotConnected
|
||||
}
|
||||
DownloadErrorKind::Io => OnlineErrorKind::Io,
|
||||
DownloadErrorKind::Integrity => OnlineErrorKind::Integrity,
|
||||
DownloadErrorKind::RangeUnsupported => OnlineErrorKind::Malformed,
|
||||
DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported,
|
||||
// 没有叶子 ⇒ 分不清「没网」与「站点挂了」,按可重试的「没联网」保守处理:
|
||||
// 上层至少会提示「检查网络后重试」,而不是宣称「没有匹配的内容」。
|
||||
DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
|
||||
};
|
||||
Self::new(kind, error.message)
|
||||
}
|
||||
|
||||
@@ -143,3 +143,80 @@ fn a_wrong_completed_sha1_fails_the_import_instead_of_installing() {
|
||||
);
|
||||
assert!(!target.join(PATH).exists(), "失败的导入不许留文件");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- r1 P1-1 后果 B 的守卫
|
||||
|
||||
const CF_SHA512: &str = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1\
|
||||
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1";
|
||||
|
||||
/// 两条远端条目:第一条**站点不认得**(等价于托管在 CurseForge CDN),第二条认得。
|
||||
///
|
||||
/// 真实 mrpack 大多是这个形状——所以「第一条补不出就掐掉整轮」是 r1 抓到的后果 B。
|
||||
fn pack_with_one_unknown_sha512(tag: &str) -> (TempRoot, std::path::PathBuf) {
|
||||
let root = TempRoot::new(tag);
|
||||
let dir = root.join("src");
|
||||
let index = format!(
|
||||
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"混托管",
|
||||
"files":[
|
||||
{{"path":"mods/hosted-on-cf.jar","hashes":{{"sha512":"{CF_SHA512}"}},
|
||||
"downloads":["stub://mods/hosted-on-cf.jar"],"fileSize":10}},
|
||||
{{"path":"{PATH}","hashes":{{"sha512":"{SHA512}"}},
|
||||
"downloads":["{DOWNLOAD}"],"fileSize":{size}}}
|
||||
],
|
||||
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
|
||||
size = PACK_BYTES.len()
|
||||
);
|
||||
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
|
||||
let pack = root.join("pack.mrpack");
|
||||
zip_dir(&dir, &pack);
|
||||
(root, pack)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_404_on_one_entry_does_not_stop_the_completion_round() {
|
||||
// 第一次查(CF 那一条)站点回 404 ⇒ **不许**停:第二条仍然要补出来并参与判定。
|
||||
let (root, pack) = pack_with_one_unknown_sha512("online-mixed-hosting");
|
||||
let target = root.join("instance");
|
||||
write_file(&target.join(PATH), DISK_BYTES);
|
||||
write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy");
|
||||
|
||||
let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&sha1(PACK_BYTES)));
|
||||
let scan = block_on(scan_pack_online(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
let paths: Vec<&str> = scan.conflicts.iter().map(|c| c.path.as_str()).collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![PATH],
|
||||
"第二条(托管在 Modrinth 的那条)必须补出来并判出冲突:{:?}",
|
||||
paths
|
||||
);
|
||||
assert_eq!(scan.unknown_remote, 1, "补不出的那一条仍如实计数");
|
||||
assert!(
|
||||
!scan
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.message.contains("未联网")),
|
||||
"404 不是「没联网」,注记不许这么说:{:?}",
|
||||
scan.notes.iter().map(|n| &n.message).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_real_offline_stops_the_completion_round() {
|
||||
let (root, pack) = pack_with_one_unknown_sha512("online-mixed-offline");
|
||||
let target = root.join("instance");
|
||||
write_file(&target.join(PATH), DISK_BYTES);
|
||||
write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy");
|
||||
|
||||
let fetcher = StubFetcher::new()
|
||||
.with_modrinth(SHA512, meta(&sha1(PACK_BYTES)))
|
||||
.offline_modrinth();
|
||||
let scan = block_on(scan_pack_online(
|
||||
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
|
||||
));
|
||||
assert!(scan.is_ok(), "{:?}", scan.error);
|
||||
assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了");
|
||||
assert!(scan.conflicts.is_empty());
|
||||
}
|
||||
@@ -55,6 +55,8 @@ pub(crate) struct StubFetcher {
|
||||
curse: BTreeMap<(i64, i64), CurseFileMeta>,
|
||||
/// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。
|
||||
modrinth: BTreeMap<String, ModrinthFileMeta>,
|
||||
/// M162:补全时假装「没联网」(与「站点不认得这个摘要」区分开)。
|
||||
modrinth_offline: bool,
|
||||
/// 被请求过的 URL(断言"没白下载"用)。
|
||||
calls: Mutex<Vec<String>>,
|
||||
/// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。
|
||||
@@ -68,6 +70,7 @@ impl StubFetcher {
|
||||
files: BTreeMap::new(),
|
||||
curse: BTreeMap::new(),
|
||||
modrinth: BTreeMap::new(),
|
||||
modrinth_offline: false,
|
||||
calls: Mutex::new(Vec::new()),
|
||||
artifacts: false,
|
||||
}
|
||||
@@ -91,6 +94,12 @@ impl StubFetcher {
|
||||
self
|
||||
}
|
||||
|
||||
/// 让补全路径假装「没联网」(`FetchErrorKind::Offline`)。
|
||||
pub(crate) fn offline_modrinth(mut self) -> Self {
|
||||
self.modrinth_offline = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。
|
||||
pub(crate) fn with_engine_artifacts(mut self) -> Self {
|
||||
self.artifacts = true;
|
||||
@@ -127,6 +136,12 @@ impl PackFetcher for StubFetcher {
|
||||
sha512: &'a str,
|
||||
) -> FetchFuture<'a, Result<ModrinthFileMeta, super::super::import::FetchError>> {
|
||||
Box::pin(async move {
|
||||
if self.modrinth_offline {
|
||||
return Err(super::super::import::FetchError::new(
|
||||
super::super::import::FetchErrorKind::Offline,
|
||||
"连不上 api.modrinth.com",
|
||||
));
|
||||
}
|
||||
self.modrinth.get(sha512).cloned().ok_or_else(|| {
|
||||
super::super::import::FetchError::new(
|
||||
super::super::import::FetchErrorKind::NotFound,
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::time::Instant;
|
||||
|
||||
use super::cache::now_ms;
|
||||
use super::engine::{millis, DownloadEngine};
|
||||
use super::error::{DownloadError, DownloadErrorKind};
|
||||
use super::error::{leaf_of, DownloadError, DownloadErrorKind};
|
||||
use super::http;
|
||||
use super::progress::ProgressEvent;
|
||||
use super::types::{Candidate, DownloadId, DownloadRequest};
|
||||
@@ -33,6 +33,8 @@ impl DownloadEngine {
|
||||
|
||||
let _permit = self.acquire().await?;
|
||||
let mut suppressed: Vec<String> = Vec::new();
|
||||
// 逐源的叶子分类(出去时汇总成一条,见 `DownloadError::leaf`)。
|
||||
let mut kinds: Vec<DownloadErrorKind> = Vec::new();
|
||||
|
||||
for candidate in &plan {
|
||||
// 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
|
||||
@@ -74,6 +76,7 @@ impl DownloadEngine {
|
||||
retry_in_ms: if retry { millis(delay) } else { 0 },
|
||||
});
|
||||
if !retry {
|
||||
kinds.push(err.kind);
|
||||
suppressed.push(format!("{}: {}", candidate.source, err.message));
|
||||
break;
|
||||
}
|
||||
@@ -84,11 +87,14 @@ impl DownloadEngine {
|
||||
}
|
||||
|
||||
self.flush_health();
|
||||
// 叶子成因要跟着出去:上层(在线内容源的 `Transport`)靠它区分「这个 URL 不存在」
|
||||
// 与「没联网」,只留 `AllSourcesFailed` 的话那套分类在生产路径上永远不可达。
|
||||
Err(DownloadError::new(
|
||||
DownloadErrorKind::AllSourcesFailed,
|
||||
format!("所有候选源都取不到文本资源:{}", req.url),
|
||||
)
|
||||
.with_suppressed(suppressed))
|
||||
.with_suppressed(suppressed)
|
||||
.with_leaf(leaf_of(kinds)))
|
||||
}
|
||||
|
||||
async fn fetch_bytes(
|
||||
|
||||
@@ -70,6 +70,18 @@ pub struct DownloadError {
|
||||
pub attempts: usize,
|
||||
/// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
|
||||
pub suppressed: Vec<String>,
|
||||
/// **叶子成因**:整体失败([`DownloadErrorKind::AllSourcesFailed`])之前,各候选源到底
|
||||
/// 是怎么挂的。
|
||||
///
|
||||
/// 为什么需要它:候选源循环结束后只剩一条 `AllSourcesFailed`,上层(在线内容源、
|
||||
/// 导入补全、自更新)拿到它就无法区分「这个项目不存在(404)」与「没联网」——
|
||||
/// 前者要提示换关键词,后者要提示检查网络。`suppressed` 里虽然有文字,但从错误串里
|
||||
/// 反解状态码是靠文案猜,改一句提示就失效。
|
||||
///
|
||||
/// 汇总规则([`leaf_of`]):全 404 ⇒ `NotFound`;出现 `Network`/`Timeout` ⇒ `Network`;
|
||||
/// 全 5xx ⇒ `ServerError`;混合按「网络错 > 5xx > 404」取最可诊断的那个。
|
||||
/// `None` = 没有汇总过(单源直接失败,或调用方自己构造的错误)。
|
||||
pub leaf: Option<DownloadErrorKind>,
|
||||
}
|
||||
|
||||
impl DownloadError {
|
||||
@@ -82,6 +94,7 @@ impl DownloadError {
|
||||
message: message.into(),
|
||||
attempts: 0,
|
||||
suppressed: Vec::new(),
|
||||
leaf: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +118,62 @@ impl DownloadError {
|
||||
self.suppressed = suppressed;
|
||||
self
|
||||
}
|
||||
|
||||
/// 标注整体失败之前的叶子成因。
|
||||
#[must_use]
|
||||
pub const fn with_leaf(mut self, leaf: DownloadErrorKind) -> Self {
|
||||
self.leaf = Some(leaf);
|
||||
self
|
||||
}
|
||||
|
||||
/// 最可诊断的成因:有叶子就用叶子,否则用自身的 kind。
|
||||
///
|
||||
/// 上层(`addon::online::transport` / `addon::import::fetch`)靠这一处判定,不各自
|
||||
/// 再写一遍「AllSourcesFailed 到底算哪一类」。
|
||||
#[must_use]
|
||||
pub fn effective_kind(&self) -> DownloadErrorKind {
|
||||
if self.kind == DownloadErrorKind::AllSourcesFailed {
|
||||
return self.leaf.unwrap_or(self.kind);
|
||||
}
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
/// 从各候选源的失败里汇总出**最可诊断**的叶子成因。
|
||||
///
|
||||
/// 规则(与 `online::transport` 的映射表配套,判据只此一处):
|
||||
///
|
||||
/// | 各源的情况 | 叶子 |
|
||||
/// |------------|------|
|
||||
/// | 全部 404/410/403(`NotFound`) | `NotFound` |
|
||||
/// | 出现 `Network` / `Timeout` | `Network` |
|
||||
/// | 全部 5xx(`ServerError`) | `ServerError` |
|
||||
/// | 其它(`Integrity` / `Io` / `InvalidRequest`) | 该条自己的 kind(后出现的赢) |
|
||||
/// | 混合了 404 与 5xx | `ServerError`(比「不存在」更可诊断) |
|
||||
///
|
||||
/// `kinds` 为空时返回 `AllSourcesFailed`(调用方据此保留原 kind)。
|
||||
#[must_use]
|
||||
pub fn leaf_of(kinds: impl IntoIterator<Item = DownloadErrorKind>) -> DownloadErrorKind {
|
||||
let mut leaf = DownloadErrorKind::AllSourcesFailed;
|
||||
let mut rank = 0u8;
|
||||
for kind in kinds {
|
||||
let (candidate, weight) = match kind {
|
||||
DownloadErrorKind::Network | DownloadErrorKind::Timeout => {
|
||||
(DownloadErrorKind::Network, 4)
|
||||
}
|
||||
DownloadErrorKind::ServerError => (DownloadErrorKind::ServerError, 3),
|
||||
DownloadErrorKind::Integrity => (DownloadErrorKind::Integrity, 3),
|
||||
DownloadErrorKind::Io => (DownloadErrorKind::Io, 3),
|
||||
DownloadErrorKind::InvalidRequest => (DownloadErrorKind::InvalidRequest, 3),
|
||||
DownloadErrorKind::NotFound => (DownloadErrorKind::NotFound, 1),
|
||||
DownloadErrorKind::RangeUnsupported | DownloadErrorKind::AllSourcesFailed => (kind, 2),
|
||||
};
|
||||
if weight >= rank {
|
||||
rank = weight;
|
||||
leaf = candidate;
|
||||
}
|
||||
}
|
||||
leaf
|
||||
}
|
||||
|
||||
impl fmt::Display for DownloadError {
|
||||
@@ -123,3 +192,59 @@ impl fmt::Display for DownloadError {
|
||||
}
|
||||
|
||||
impl std::error::Error for DownloadError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{leaf_of, DownloadError, DownloadErrorKind};
|
||||
|
||||
/// 四档叶子成因(tower 定案的门禁):全 404 / 有网络错 / 全 5xx / 混合。
|
||||
#[test]
|
||||
fn leaf_keeps_the_most_diagnosable_cause() {
|
||||
use DownloadErrorKind::{Network, NotFound, ServerError, Timeout};
|
||||
assert_eq!(leaf_of([NotFound, NotFound]), NotFound, "全 404 ⇒ NotFound");
|
||||
assert_eq!(
|
||||
leaf_of([Network, NotFound]),
|
||||
Network,
|
||||
"有网络错 ⇒ Network(哪怕另一源是 404)"
|
||||
);
|
||||
assert_eq!(
|
||||
leaf_of([ServerError, ServerError]),
|
||||
ServerError,
|
||||
"全 5xx ⇒ ServerError"
|
||||
);
|
||||
assert_eq!(
|
||||
leaf_of([NotFound, ServerError]),
|
||||
ServerError,
|
||||
"混合 404 与 5xx ⇒ 取更可诊断的那个"
|
||||
);
|
||||
assert_eq!(leaf_of([Timeout, NotFound]), Network, "超时归到 Network");
|
||||
assert_eq!(
|
||||
leaf_of([]),
|
||||
DownloadErrorKind::AllSourcesFailed,
|
||||
"一条都没有 ⇒ 保留原 kind"
|
||||
);
|
||||
}
|
||||
|
||||
/// 整体失败时上层靠 `effective_kind()` 判定;单源失败时就是它自己的 kind。
|
||||
#[test]
|
||||
fn effective_kind_prefers_the_leaf_only_for_all_sources_failed() {
|
||||
let leaf = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
|
||||
.with_leaf(DownloadErrorKind::NotFound);
|
||||
assert_eq!(leaf.effective_kind(), DownloadErrorKind::NotFound);
|
||||
assert_eq!(
|
||||
leaf.kind,
|
||||
DownloadErrorKind::AllSourcesFailed,
|
||||
"kind 本身不改"
|
||||
);
|
||||
|
||||
let bare = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败");
|
||||
assert_eq!(
|
||||
bare.effective_kind(),
|
||||
DownloadErrorKind::AllSourcesFailed,
|
||||
"没有叶子 ⇒ 原样返回(上层再决定当哪一类)"
|
||||
);
|
||||
|
||||
let single = DownloadError::new(DownloadErrorKind::NotFound, "404");
|
||||
assert_eq!(single.effective_kind(), DownloadErrorKind::NotFound);
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,8 @@ macro_rules! content_entries {
|
||||
ContentFailedBody => ("Check the network, then retry.", "检查网络后重试。"),
|
||||
ContentOfflineTitle => ("No connection", "未联网"),
|
||||
ContentOfflineBody => (
|
||||
"Content search needs the network and nothing is cached yet. Check your connection, then retry.",
|
||||
"内容检索需要联网,本地还没有缓存。检查网络后重试。"
|
||||
"Content search needs the network. Check your connection, then retry.",
|
||||
"内容检索需要联网。检查网络后重试。"
|
||||
),
|
||||
ContentRetry => ("Retry", "重试"),
|
||||
ContentLoadMore => ("Load more", "加载更多"),
|
||||
|
||||
@@ -181,7 +181,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("ContentFailedTitle", "Could not load content", "内容加载失败"),
|
||||
("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"),
|
||||
("ContentOfflineTitle", "No connection", "未联网"),
|
||||
("ContentOfflineBody", "Content search needs the network and nothing is cached yet. Check your connection, then retry.", "内容检索需要联网,本地还没有缓存。检查网络后重试。"),
|
||||
("ContentOfflineBody", "Content search needs the network. Check your connection, then retry.", "内容检索需要联网。检查网络后重试。"),
|
||||
("ContentRetry", "Retry", "重试"),
|
||||
("ContentLoadMore", "Load more", "加载更多"),
|
||||
("ContentNoMore", "No more results", "没有更多了"),
|
||||
|
||||
@@ -308,8 +308,10 @@ pub struct ContentState {
|
||||
pub last_page_filled: bool,
|
||||
/// 选中项目的详情(M162:`LoadContentDetail` 的结果;`None` = 还没拉过)
|
||||
pub detail: Option<Box<ContentDetail>>,
|
||||
/// 详情请求在飞
|
||||
/// 详情请求在飞(哪个项目:迟到结果靠它判「还算不算当前这次」)
|
||||
pub detail_pending: bool,
|
||||
/// 在飞的那次详情请求的项目 id(`None` = 没有在飞)
|
||||
pub detail_requested: Option<String>,
|
||||
/// 最近一次详情请求的失败
|
||||
pub detail_failed: Option<ContentFailure>,
|
||||
}
|
||||
@@ -324,6 +326,7 @@ impl Default for ContentState {
|
||||
last_page_filled: false,
|
||||
detail: None,
|
||||
detail_pending: false,
|
||||
detail_requested: None,
|
||||
detail_failed: None,
|
||||
}
|
||||
}
|
||||
@@ -340,22 +343,40 @@ impl ContentState {
|
||||
self.detail = None;
|
||||
}
|
||||
self.detail_pending = true;
|
||||
self.detail_requested = Some(project.to_string());
|
||||
self.detail_failed = None;
|
||||
}
|
||||
|
||||
/// 详情落地(迟到的结果(换了选中项)由调用方按 `project` 判)。
|
||||
pub fn apply_detail(&mut self, detail: ContentDetail) {
|
||||
/// 详情落地:**只接受当前这次请求的结果**(迟到的、别的项目的一律丢掉)。
|
||||
///
|
||||
/// 场景:选中 A(请求发出)→ 立刻切到 B(又发一条)→ A 的结果后到。没有这道闸门的话,
|
||||
/// A 的版本列表会画在 B 的详情面板下,A 的失败也会被当成本次失败(r1 P2-3)。
|
||||
pub fn apply_detail(&mut self, project: &str, detail: ContentDetail) {
|
||||
if !self.accepts_detail(project) || detail.project != project {
|
||||
return;
|
||||
}
|
||||
self.detail_pending = false;
|
||||
self.detail_requested = None;
|
||||
self.detail_failed = None;
|
||||
self.detail = Some(Box::new(detail));
|
||||
}
|
||||
|
||||
/// 详情失败。
|
||||
pub fn fail_detail(&mut self, failure: ContentFailure) {
|
||||
/// 详情失败:同样只看当前这次请求。
|
||||
pub fn fail_detail(&mut self, project: &str, failure: ContentFailure) {
|
||||
if !self.accepts_detail(project) {
|
||||
return;
|
||||
}
|
||||
self.detail_pending = false;
|
||||
self.detail_requested = None;
|
||||
self.detail_failed = Some(failure);
|
||||
}
|
||||
|
||||
/// 这条详情结果还算当前这次吗(`false` = 迟到 / 用户已经换了选中项)。
|
||||
#[must_use]
|
||||
pub fn accepts_detail(&self, project: &str) -> bool {
|
||||
self.detail_requested.as_deref() == Some(project)
|
||||
}
|
||||
|
||||
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
|
||||
#[must_use]
|
||||
pub fn accepts(&self, results: &ContentResults) -> bool {
|
||||
|
||||
@@ -139,8 +139,16 @@ pub enum CoreEvent {
|
||||
ContentResults(Box<ContentResults>),
|
||||
/// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画
|
||||
ContentFailed(Box<crate::dto::ContentFailure>),
|
||||
/// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道
|
||||
ContentDetail(Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>),
|
||||
/// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道。
|
||||
///
|
||||
/// `project` 是**请求时选中的那个 id**:迟到的结果要靠它判「还算不算当前这次」
|
||||
/// (用户可能已经切到别的项目了)。
|
||||
ContentDetail {
|
||||
/// 请求时选中的项目 id
|
||||
project: String,
|
||||
/// `Ok` = 详情;`Err` = 失败大类 + 原因
|
||||
result: Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>,
|
||||
},
|
||||
// M113: import/export
|
||||
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
|
||||
Pack(Box<PackEvent>),
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dto::ContentFailureKind;
|
||||
|
||||
/// 安装阶段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum InstallStage {
|
||||
@@ -70,6 +72,11 @@ pub struct InstallProgressView {
|
||||
pub eta_secs: u64,
|
||||
/// 状态/错误文案
|
||||
pub message: String,
|
||||
/// 失败大类(M162 r1):`None` = 没失败,或失败来自不产生分类的路径。
|
||||
///
|
||||
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
|
||||
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
|
||||
pub failure_kind: Option<ContentFailureKind>,
|
||||
}
|
||||
|
||||
impl Default for InstallProgressView {
|
||||
@@ -93,6 +100,7 @@ impl InstallProgressView {
|
||||
speed_bps: 0,
|
||||
eta_secs: 0,
|
||||
message: String::new(),
|
||||
failure_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,12 @@ pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) {
|
||||
/// 后台取项目详情 + 版本列表(M162;选中项目才发,一条独立通道)。
|
||||
pub fn spawn_detail(env: HostEnv, tx: JobSender, request: ContentDetailRequest) {
|
||||
spawn("content-detail", move || {
|
||||
let project = request.project.clone();
|
||||
let result: Result<ContentDetail, ContentFailure> = sources::detail(&env, &request);
|
||||
let _ = tx.send(HostTask::ContentDetail(Box::new(result)));
|
||||
let _ = tx.send(HostTask::ContentDetail {
|
||||
project,
|
||||
result: Box::new(result),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! 「一键装入指定实例」(M109)。
|
||||
//!
|
||||
//! ## 网络只走 code 的在线内容源(M162 r1)
|
||||
//!
|
||||
//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、
|
||||
//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。
|
||||
//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
|
||||
//!
|
||||
//! ## 它到底做了什么(诚实版)
|
||||
//!
|
||||
//! | 内容类型 | 做什么 | 状态 |
|
||||
@@ -19,20 +25,21 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{download_verified, EngineArtifactFetcher};
|
||||
use unlucky_core::addon::online::{
|
||||
download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::derive_game_dir;
|
||||
|
||||
use crate::dto::{
|
||||
loader_facet, ContentInstallRequest, ContentSource, InstallProgressView, InstallStage,
|
||||
InstanceSummary,
|
||||
loader_facet, ContentFailure, ContentFailureKind, ContentInstallRequest, ContentSource,
|
||||
InstallProgressView, InstallStage, InstanceSummary,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::http::{encode, get};
|
||||
use super::sources::MODRINTH;
|
||||
use super::sources::failure_of;
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
@@ -56,7 +63,7 @@ pub(super) fn install(
|
||||
fail(
|
||||
tx,
|
||||
progress,
|
||||
"整合包安装尚未开放;内容库对整合包只做检索",
|
||||
ContentFailure::unsupported("整合包安装尚未开放;内容库对整合包只做检索"),
|
||||
epoch,
|
||||
);
|
||||
return;
|
||||
@@ -77,32 +84,35 @@ pub(super) fn install(
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
Err(reason) => {
|
||||
Err(failure) => {
|
||||
progress.stage = if cancel.load(Ordering::SeqCst) {
|
||||
InstallStage::Cancelled
|
||||
} else {
|
||||
InstallStage::Failed
|
||||
};
|
||||
progress.message = failure.detail.clone();
|
||||
progress.failure_kind = Some(failure.kind);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason),
|
||||
result: Err(failure),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(tx: &JobSender, mut progress: InstallProgressView, reason: &str, epoch: u64) {
|
||||
fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailure, epoch: u64) {
|
||||
progress.stage = InstallStage::Failed;
|
||||
progress.message = reason.to_string();
|
||||
progress.message = failure.detail.clone();
|
||||
progress.failure_kind = Some(failure.kind);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason.to_string()),
|
||||
result: Err(failure),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 真正干活的那一段,返回一句人读的落盘描述。
|
||||
/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。
|
||||
fn run(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
@@ -110,20 +120,20 @@ fn run(
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, ContentFailure> {
|
||||
let instance = find_instance(env, &request.instance)?;
|
||||
let game_dir = game_dir_of(env, &instance)?;
|
||||
let dir = request
|
||||
.kind
|
||||
.dir()
|
||||
.ok_or_else(|| "该内容类型不支持单文件装入".to_string())?;
|
||||
.ok_or_else(|| local("该内容类型不支持单文件装入"))?;
|
||||
let target_dir = game_dir.join(dir);
|
||||
std::fs::create_dir_all(&target_dir)
|
||||
.map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?;
|
||||
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
|
||||
|
||||
let file = resolve_file(env, request)?;
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err("已取消(下载开始前)".to_string());
|
||||
return Err(local("已取消(下载开始前)"));
|
||||
}
|
||||
progress.stage = InstallStage::Downloading;
|
||||
progress.message = file.name.clone();
|
||||
@@ -145,22 +155,27 @@ fn run(
|
||||
file.size,
|
||||
&dest,
|
||||
))
|
||||
.map_err(|error| format!("下载失败:{error}"))?;
|
||||
.map_err(failure_of)?;
|
||||
Ok(format!("{}({})", file.name, dest.display()))
|
||||
}
|
||||
|
||||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, String> {
|
||||
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
|
||||
fn local(reason: impl Into<String>) -> ContentFailure {
|
||||
ContentFailure::new(ContentFailureKind::Other, reason)
|
||||
}
|
||||
|
||||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFailure> {
|
||||
super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| format!("找不到实例 {id}"))
|
||||
.ok_or_else(|| local(format!("找不到实例 {id}")))
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
|
||||
let global = super::super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
|
||||
derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
@@ -169,7 +184,7 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Str
|
||||
None,
|
||||
)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| format!("隔离目录派生失败:{error}"))
|
||||
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
||||
}
|
||||
|
||||
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
|
||||
@@ -182,72 +197,56 @@ struct RemoteFile {
|
||||
}
|
||||
|
||||
/// 解析「这个项目下最适合目标实例的那个文件」。
|
||||
fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
|
||||
fn resolve_file(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
) -> Result<RemoteFile, ContentFailure> {
|
||||
match request.source {
|
||||
ContentSource::Modrinth => modrinth_file(env, request),
|
||||
ContentSource::Curseforge => Err(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(),
|
||||
),
|
||||
ContentSource::Curseforge => Err(ContentFailure::unsupported(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
|
||||
let mut url = format!(
|
||||
"{MODRINTH}/project/{}/version?game_versions={}",
|
||||
request.project,
|
||||
encode(&format!("[\"{}\"]", request.game_version))
|
||||
);
|
||||
if let Some(facet) = loader_facet(request.loader) {
|
||||
url.push_str(&format!("&loaders={}", encode(&format!("[\"{facet}\"]"))));
|
||||
/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。
|
||||
///
|
||||
/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑,
|
||||
/// `Version::primary_file()` 就是为这件事存在的。
|
||||
fn modrinth_file(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
) -> Result<RemoteFile, ContentFailure> {
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
let versions = block_on(client.versions(&VersionQuery {
|
||||
project: request.project.clone(),
|
||||
game_version: Some(request.game_version.clone()),
|
||||
loader: loader_facet(request.loader).map(str::to_string),
|
||||
}))
|
||||
.map_err(failure_of)?;
|
||||
let version = versions.first().ok_or_else(|| {
|
||||
ContentFailure::new(
|
||||
ContentFailureKind::NotFound,
|
||||
"这个项目没有匹配目标实例(版本 / 加载器)的文件",
|
||||
)
|
||||
})?;
|
||||
let file = version
|
||||
.primary_file()
|
||||
.ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?;
|
||||
if file.url.trim().is_empty() {
|
||||
return Err(ContentFailure::new(
|
||||
ContentFailureKind::Unsupported,
|
||||
"版本里的主文件没有下载地址",
|
||||
));
|
||||
}
|
||||
let body = get(env, &url, &[])?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("Modrinth 版本列表不是合法 JSON:{error}"))?;
|
||||
let versions = json
|
||||
.as_array()
|
||||
.ok_or_else(|| "Modrinth 版本列表不是数组".to_string())?;
|
||||
let first = versions
|
||||
.first()
|
||||
.ok_or_else(|| "这个项目没有匹配目标实例(版本 / 加载器)的文件".to_string())?;
|
||||
let files = first
|
||||
.get("files")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "版本里没有 files".to_string())?;
|
||||
// 显式选 `primary: true`(Modrinth 官方口径):取第一个会拿到 sources jar 这类坑
|
||||
let file = files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
file.get("primary")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.or_else(|| files.first())
|
||||
.ok_or_else(|| "版本里没有可用文件".to_string())?;
|
||||
|
||||
let url = file
|
||||
.get("url")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "文件没有 url".to_string())?;
|
||||
let name = file
|
||||
.get("filename")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "文件没有 filename".to_string())?;
|
||||
let digest = |algorithm: &str| {
|
||||
file.get("hashes")
|
||||
.and_then(|hashes| hashes.get(algorithm))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|hex| !hex.trim().is_empty())
|
||||
.map(ToString::to_string)
|
||||
};
|
||||
Ok(RemoteFile {
|
||||
url: url.to_string(),
|
||||
name: safe_file_name(name),
|
||||
sha1: digest("sha1"),
|
||||
sha512: digest("sha512"),
|
||||
size: file
|
||||
.get("size")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.filter(|size| *size > 0),
|
||||
url: file.url.clone(),
|
||||
name: safe_file_name(&file.filename),
|
||||
sha1: file.sha1().map(ToString::to_string),
|
||||
sha512: file.sha512().map(ToString::to_string),
|
||||
size: (file.size > 0).then_some(file.size),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ use super::http::{encode, get};
|
||||
use unlucky_core::addon::online::EngineTransport;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
/// Modrinth v2 的根(无需 key)。
|
||||
pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2";
|
||||
// Modrinth 的根不再在本文件:URL 拼装与解析都在 `core::addon::online`,这里只留 CF 的
|
||||
// (它要 `X-API-KEY`,是 core 那一侧**故意**没接的那半)。
|
||||
/// CurseForge v1 的根(需要 `X-API-KEY`)。
|
||||
pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
|
||||
/// CurseForge 的「Minecraft」gameId(官方固定值)。
|
||||
|
||||
@@ -316,7 +316,10 @@ fn progress_from_a_cancelled_job_is_dropped() {
|
||||
});
|
||||
host.handle(HostTask::InstallDone {
|
||||
progress: Box::new(progress_snapshot(512)),
|
||||
result: Err("已取消".to_string()),
|
||||
result: Err(crate::dto::ContentFailure::new(
|
||||
crate::dto::ContentFailureKind::Other,
|
||||
"已取消",
|
||||
)),
|
||||
epoch,
|
||||
});
|
||||
let after: Vec<AppEvent> = host.drain_events();
|
||||
|
||||
@@ -41,8 +41,8 @@ impl CoreHost {
|
||||
Err(failure) => self.send(CoreEvent::ContentFailed(Box::new(failure))),
|
||||
},
|
||||
// M162:详情/版本列表(与检索同一套失败分类)
|
||||
HostTask::ContentDetail(result) => {
|
||||
self.send(CoreEvent::ContentDetail(result));
|
||||
HostTask::ContentDetail { project, result } => {
|
||||
self.send(CoreEvent::ContentDetail { project, result });
|
||||
}
|
||||
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
|
||||
HostTask::Pack { event, epoch } => {
|
||||
@@ -86,7 +86,9 @@ impl CoreHost {
|
||||
self.refresh_instances();
|
||||
self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env)));
|
||||
}
|
||||
Err(reason) => self.toast(ToastKind::Error, reason),
|
||||
// 只把**一句人读原因**弹出去:大类已经随 `progress.failure_kind` 进了 DTO,
|
||||
// 面板/调用方按大类决定怎么画(离线 vs 查无此项)。
|
||||
Err(failure) => self.toast(ToastKind::Error, failure.detail),
|
||||
}
|
||||
}
|
||||
HostTask::Accounts(accounts) => {
|
||||
|
||||
@@ -160,9 +160,15 @@ pub fn spawn_install(
|
||||
format!("{reason}(日志:{})", journal.path().display())
|
||||
};
|
||||
progress.message.clone_from(&text);
|
||||
// 版本安装这条路的失败不是内容库的分类(它是本地/运行期错误),归到 Other:
|
||||
// 大类字段仍然要填,面板才不会把「装坏了」误画成「没联网」。
|
||||
progress.failure_kind = Some(crate::dto::ContentFailureKind::Other);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(text),
|
||||
result: Err(crate::dto::ContentFailure::new(
|
||||
crate::dto::ContentFailureKind::Other,
|
||||
text,
|
||||
)),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ pub enum HostTask {
|
||||
},
|
||||
/// 内容库检索(M109;`Err` = 失败大类 + 一句人读原因)
|
||||
Content(Box<Result<ContentResults, ContentFailure>>),
|
||||
/// 内容库项目详情 + 版本列表(M162)
|
||||
ContentDetail(Box<Result<ContentDetail, ContentFailure>>),
|
||||
/// 内容库项目详情 + 版本列表(M162):项目 id 随载荷一起回来(迟到结果靠它判)
|
||||
ContentDetail {
|
||||
/// 请求时选中的项目 id
|
||||
project: String,
|
||||
/// `Ok` = 详情;`Err` = 失败大类 + 原因
|
||||
result: Box<Result<ContentDetail, ContentFailure>>,
|
||||
},
|
||||
// M113: import/export
|
||||
/// 导入/导出(M113):一条事件 + 任务代次
|
||||
///
|
||||
@@ -74,12 +79,12 @@ pub enum HostTask {
|
||||
/// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。
|
||||
epoch: u64,
|
||||
},
|
||||
/// 安装收尾(成功带实例名,失败带文案)
|
||||
/// 安装收尾(成功带实例名,失败带**大类 + 文案**)
|
||||
InstallDone {
|
||||
/// 最终进度快照
|
||||
progress: Box<InstallProgressView>,
|
||||
/// 成功时为 `Ok(实例名)`
|
||||
result: Result<String, String>,
|
||||
/// 成功时为 `Ok(实例名)`;失败时带分类(离线 ≠ 查无此项)
|
||||
result: Result<String, ContentFailure>,
|
||||
/// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`)
|
||||
epoch: u64,
|
||||
},
|
||||
|
||||
@@ -301,9 +301,9 @@ impl AppState {
|
||||
self.content.failed = Some(*failure);
|
||||
}
|
||||
// M162:详情/版本列表(选中项才拉;迟到结果按项目 id 丢)
|
||||
CoreEvent::ContentDetail(result) => match *result {
|
||||
Ok(detail) => self.content.apply_detail(detail),
|
||||
Err(failure) => self.content.fail_detail(failure),
|
||||
CoreEvent::ContentDetail { project, result } => match *result {
|
||||
Ok(detail) => self.content.apply_detail(&project, detail),
|
||||
Err(failure) => self.content.fail_detail(&project, failure),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@ fn the_detail_channel_replaces_the_previous_project() {
|
||||
let mut state = AppState::new();
|
||||
state.content.request_detail("p1");
|
||||
assert!(state.content.detail_pending);
|
||||
state.apply_core(CoreEvent::ContentDetail(Box::new(Ok(detail("p1")))));
|
||||
state.apply_core(CoreEvent::ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
result: Box::new(Ok(detail("p1"))),
|
||||
});
|
||||
assert!(!state.content.detail_pending);
|
||||
assert_eq!(
|
||||
state.content.detail.as_ref().map(|d| d.project.as_str()),
|
||||
@@ -170,9 +173,10 @@ fn the_detail_channel_replaces_the_previous_project() {
|
||||
state.content.detail.is_none(),
|
||||
"换了项目就不能留着上一个的版本列表"
|
||||
);
|
||||
state.apply_core(CoreEvent::ContentDetail(Box::new(Err(
|
||||
ContentFailure::offline("connect timed out"),
|
||||
))));
|
||||
state.apply_core(CoreEvent::ContentDetail {
|
||||
project: "p2".to_string(),
|
||||
result: Box::new(Err(ContentFailure::offline("connect timed out"))),
|
||||
});
|
||||
assert!(state
|
||||
.content
|
||||
.detail_failed
|
||||
@@ -199,3 +203,42 @@ fn detail(project: &str) -> ContentDetail {
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// r1 P2-3:迟到的详情结果(A)不许写进 B 的请求/面板。
|
||||
#[test]
|
||||
fn a_late_detail_result_for_the_previous_project_is_dropped() {
|
||||
let mut state = AppState::new();
|
||||
state.content.request_detail("p1");
|
||||
// 用户立刻切到 p2:新的请求在飞
|
||||
state.content.request_detail("p2");
|
||||
// p1 的失败这时才回来
|
||||
state.apply_core(CoreEvent::ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
result: Box::new(Err(ContentFailure::offline("connect timed out"))),
|
||||
});
|
||||
assert!(
|
||||
state.content.detail_failed.is_none(),
|
||||
"A 的失败不许画在 B 的面板上"
|
||||
);
|
||||
assert!(state.content.detail_pending, "B 仍应在飞");
|
||||
assert_eq!(state.content.detail_requested.as_deref(), Some("p2"));
|
||||
|
||||
// p1 的成功结果同样被丢掉(哪怕载荷里写着 p1)
|
||||
state.apply_core(CoreEvent::ContentDetail {
|
||||
project: "p1".to_string(),
|
||||
result: Box::new(Ok(detail("p1"))),
|
||||
});
|
||||
assert!(state.content.detail.is_none());
|
||||
assert!(state.content.detail_pending);
|
||||
|
||||
// 真正属于 B 的结果才落地
|
||||
state.apply_core(CoreEvent::ContentDetail {
|
||||
project: "p2".to_string(),
|
||||
result: Box::new(Ok(detail("p2"))),
|
||||
});
|
||||
assert!(!state.content.detail_pending);
|
||||
assert_eq!(
|
||||
state.content.detail.as_ref().map(|d| d.project.as_str()),
|
||||
Some("p2")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user