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:
AskaEth committed 2026-11-07 17:28:36 +08:00
1 parent 81278f55af
commit b394316df4
30 files changed
+658 -172

No files matched your search

+26 -5
View File
@@ -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 {
+10 -2
View File
@@ -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>),
+8
View File
@@ -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,
}
}
+5 -1
View File
@@ -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),
});
});
}
+80 -81
View File
@@ -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),
})
}
+2 -2
View File
@@ -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(官方固定值)。
+4 -1
View File
@@ -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();
+5 -3
View File
@@ -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) => {
+7 -1
View File
@@ -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,
});
}
+10 -5
View File
@@ -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,
},
+3 -3
View File
@@ -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),
},
}
}
+47 -4
View File
@@ -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")
);
}