feat(m162): Modrinth 在线内容源(匿名)+ 远端条目按 sha512 补全 + 离线态

core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。

core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。

core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。

presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。

app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。

测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
This commit is contained in:
AskaEth committed 2026-11-07 17:28:36 +08:00
1 parent 0241f793b2
commit 81278f55af
50 files changed
+3794 -222

No files matched your search

+72 -1
View File
@@ -4,7 +4,8 @@
//! `has_more` 必须读「最近一页是否装满」,不能读累计条数——否则第 2 页之后恒真。
use crate::dto::{
AppEvent, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, CoreEvent,
AppEvent, ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind,
ContentQuery, ContentResults, ContentSource, ContentVersionView, CoreEvent,
};
use super::AppState;
@@ -128,3 +129,73 @@ fn a_stale_batch_does_not_touch_the_page_flag() {
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(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(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),
}],
}
}