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 增详情迟到闸门。
245 lines
8.5 KiB
Rust
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")
|
|
);
|
|
}
|