Files
Unlucky-Launcher/crates/presenter/src/state/content_tests.rs
T
AskaEth b394316df4 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 增详情迟到闸门。
2026-11-07 17:28:36 +08:00

245 lines
8.5 KiB
Rust

//! 内容库状态机的守门测试(M109;复审 P1-2 的补课)。
//!
//! 从 `state.rs` 拆出(那个文件贴 400 行硬上限)。钉的是**翻页判据**:
//! `has_more` 必须读「最近一页是否装满」,不能读累计条数——否则第 2 页之后恒真。
use crate::dto::{
AppEvent, ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind,
ContentQuery, ContentResults, ContentSource, ContentVersionView, CoreEvent,
};
use super::AppState;
fn hit(index: usize) -> ContentHit {
ContentHit {
id: format!("p{index}"),
slug: format!("p{index}"),
title: format!("Project {index}"),
summary: "s".to_string(),
author: "a".to_string(),
downloads: 0,
updated: "2026-01-01".to_string(),
source: ContentSource::Modrinth,
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
icon: None,
}
}
/// 造一页结果:`offset` 是游标,`count` 是这一页实际回来几条。
fn page(offset: u32, count: u32) -> ContentResults {
let mut query = ContentQuery::first(ContentKind::Mod);
query.offset = offset;
ContentResults {
query,
hits: (0..count)
.map(|index| hit(offset as usize + index as usize))
.collect(),
total: 999,
curseforge: false,
}
}
fn apply(state: &mut AppState, results: ContentResults) {
state.apply(AppEvent::Core(CoreEvent::ContentResults(Box::new(results))));
}
/// 复审 P1-2 的第一条:装满一页 ⇒ 还有更多;下一(末)页不满 ⇒ 没有了。
#[test]
fn a_short_last_page_clears_has_more() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
assert!(state.content.last_page_filled, "首页装满 ⇒ 还有下一页");
assert_eq!(
state.content.results.as_ref().map_or(0, |r| r.hits.len()),
limit as usize
);
// 末页只回来 3 条:累计 23 条「够一页了」,但**这一页**没装满 ⇒ 没有更多
apply(&mut state, page(limit, 3));
assert!(
!state.content.last_page_filled,
"末页不满 ⇒ 不许说还有更多(判据不能读累计条数)"
);
assert_eq!(
state.content.results.as_ref().map_or(0, |r| r.hits.len()),
limit as usize + 3,
"命中项仍然要累加"
);
}
/// 复审 P1-2 的第二条:再点一次,回来的空页仍然是「没有更多」。
#[test]
fn an_empty_last_page_keeps_has_more_false() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
apply(&mut state, page(limit, 0));
assert!(!state.content.last_page_filled, "空页 ⇒ 没有更多");
// 用户又点了一次「加载更多」:空页不会把状态翻回真
apply(&mut state, page(limit + limit, 0));
assert!(
!state.content.last_page_filled,
"再点一次空页 ⇒ 仍然没有更多"
);
assert_eq!(
state.content.results.as_ref().map_or(0, |r| r.hits.len()),
limit as usize,
"空页不改变列表内容"
);
}
/// 换条件(offset 回到 0)时把「最近一页装满」清掉:不然新条件的第一帧就会画「没有更多了」。
#[test]
fn changing_the_query_resets_the_page_flag() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
assert!(state.content.last_page_filled);
let mut query = ContentQuery::first(ContentKind::Mod);
query.text = "sodium".to_string();
state.set_content_query(query);
assert!(!state.content.last_page_filled, "换条件 ⇒ 清掉上一批的判据");
assert!(state.content.pending);
assert!(
state.content.results.is_none(),
"换条件要清列表(不然加载态画旧命中项)"
);
}
/// 迟到的一批不许改写判据(用户已经换了条件)。
#[test]
fn a_stale_batch_does_not_touch_the_page_flag() {
let mut state = AppState::new();
let limit = ContentQuery::first(ContentKind::Mod).limit;
apply(&mut state, page(0, limit));
let mut query = ContentQuery::first(ContentKind::Mod);
query.text = "sodium".to_string();
state.set_content_query(query);
// 旧条件的一页这时才回来:既不改结果,也不改「还有更多」
apply(&mut state, page(limit, 5));
assert!(!state.content.last_page_filled);
assert!(state.content.results.is_none(), "迟到的一批被丢掉");
}
// ------------------------------------------------ M162:失败大类与详情通道
/// 事件 → 状态:`ContentFailed` 带大类,没联网时 `is_offline()` 为真(页面据此画离线态)。
#[test]
fn a_content_failure_keeps_its_kind() {
let mut state = AppState::new();
state.set_content_query(ContentQuery::first(ContentKind::Mod));
state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::offline(
"connect timed out",
))));
let failure = state.content.failed.as_ref().expect("要记住失败");
assert!(failure.is_offline(), "没联网必须能被页面认出来");
assert!(!state.content.pending);
state.apply_core(CoreEvent::ContentFailed(Box::new(ContentFailure::new(
ContentFailureKind::Server,
"HTTP 503",
))));
let failure = state.content.failed.as_ref().expect("要记住失败");
assert!(!failure.is_offline(), "站点 5xx 不是「没联网」");
}
/// 详情通道:请求态 → 结果态;换项目时旧详情先清掉。
#[test]
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 {
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()),
Some("p1")
);
state.content.request_detail("p2");
assert!(
state.content.detail.is_none(),
"换了项目就不能留着上一个的版本列表"
);
state.apply_core(CoreEvent::ContentDetail {
project: "p2".to_string(),
result: Box::new(Err(ContentFailure::offline("connect timed out"))),
});
assert!(state
.content
.detail_failed
.as_ref()
.is_some_and(ContentFailure::is_offline));
}
fn detail(project: &str) -> ContentDetail {
ContentDetail {
project: project.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),
}],
}
}
/// 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")
);
}