feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)
用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。
- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
(Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
`ULL_CONTENT_STATE` 供出图矩阵选类型与状态
取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)
门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
This commit is contained in:
1 parent
5adac230fe
commit
6b161227d8
42 files changed
+4071
-25
No files matched your search
@@ -0,0 +1,87 @@
|
||||
//! 内容库 DTO 的守门测试(M109)。从 `dto/content.rs` 拆出:那个文件已贴 400 行硬上限。
|
||||
|
||||
use super::*;
|
||||
|
||||
fn hit(game_versions: Vec<String>, loaders: Vec<String>) -> ContentHit {
|
||||
ContentHit {
|
||||
id: "1".to_string(),
|
||||
slug: "s".to_string(),
|
||||
title: "t".to_string(),
|
||||
summary: String::new(),
|
||||
author: String::new(),
|
||||
downloads: 0,
|
||||
updated: String::new(),
|
||||
source: ContentSource::Modrinth,
|
||||
game_versions,
|
||||
loaders,
|
||||
icon: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_kinds_have_install_directories() {
|
||||
// 抓得住:把整合包也塞一个目录(那等于把「解压+补全+登记实例」偷偷降级成「拷一个文件进 mods」,
|
||||
// 正是任务书第 3 条禁止的事)。
|
||||
assert_eq!(ContentKind::Modpack.dir(), None);
|
||||
assert_eq!(ContentKind::Mod.dir(), Some("mods"));
|
||||
assert_eq!(ContentKind::Resourcepack.dir(), Some("resourcepacks"));
|
||||
assert_eq!(ContentKind::Shader.dir(), Some("shaderpacks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_map_to_the_official_facets_and_class_ids() {
|
||||
assert_eq!(ContentKind::Modpack.modrinth_type(), "modpack");
|
||||
assert_eq!(ContentKind::Shader.modrinth_type(), "shader");
|
||||
// CF 的四个 classId 是官方固定值,抄错就搜不到东西(而且不会报错,只会空列表)
|
||||
assert_eq!(ContentKind::Mod.curseforge_class(), 6);
|
||||
assert_eq!(ContentKind::Resourcepack.curseforge_class(), 12);
|
||||
assert_eq!(ContentKind::Modpack.curseforge_class(), 4471);
|
||||
assert_eq!(ContentKind::Shader.curseforge_class(), 6552);
|
||||
for kind in ContentKind::ALL {
|
||||
assert!(!kind.slug().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_keeps_its_filters_and_only_moves_the_cursor() {
|
||||
let mut query = ContentQuery::first(ContentKind::Mod);
|
||||
query.game_version = Some("1.20.1".to_string());
|
||||
query.text = "sodium".to_string();
|
||||
|
||||
let next = query.next_page();
|
||||
assert_eq!(next.offset, ContentQuery::PAGE, "游标按一页走");
|
||||
assert!(next.same_filters(&query), "换页不改筛选条件");
|
||||
|
||||
let mut other = next.clone();
|
||||
other.text = "lithium".to_string();
|
||||
assert!(!other.same_filters(&query), "关键词变了就不是同一批条件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_checks_treat_missing_site_data_as_unknown_not_incompatible() {
|
||||
let empty = hit(Vec::new(), Vec::new());
|
||||
// 站点没给数据时不许拦人(否则「不知道」会被画成「不兼容」)
|
||||
assert!(empty.supports_version("1.20.1"));
|
||||
assert!(empty.supports_loader(LoaderKind::Fabric));
|
||||
|
||||
let filled = hit(vec!["1.20.1".to_string()], vec!["fabric".to_string()]);
|
||||
assert!(filled.supports_version("1.20.1"));
|
||||
assert!(!filled.supports_version("1.21.9"));
|
||||
assert!(filled.supports_loader(LoaderKind::Fabric));
|
||||
assert!(!filled.supports_loader(LoaderKind::Forge));
|
||||
// 原版没有 facet:不该被加载器过滤误伤
|
||||
assert!(filled.supports_loader(LoaderKind::Vanilla));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_more_is_driven_by_a_full_page() {
|
||||
let mut results = ContentResults {
|
||||
query: ContentQuery::first(ContentKind::Mod),
|
||||
hits: Vec::new(),
|
||||
total: 999,
|
||||
curseforge: false,
|
||||
};
|
||||
assert!(!results.has_more(), "空页 = 没有更多了");
|
||||
results.hits = vec![hit(Vec::new(), Vec::new()); ContentQuery::PAGE as usize];
|
||||
assert!(results.has_more(), "装满一页 = 还有下一页");
|
||||
}
|
||||
Reference in new issue
Block a user