复审 r2 两条 p2,都修了,并按复审给的口径验过红。
**P2-1(续):不兼容档不再只有裸 id**
`FailureKind::Incompatible` 的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),
所以失败路径上值得现取一次名字——`plan::names_for_incompatible` 按失败项去重、一项一次 GET
(`Modrinth::project`),`fetch_title` 把任何失败都折成 `None`:
**拿名字失败不许影响安装结果**,取不到 / 拿到空标题就退回 id。只有这一档发请求;
「取不到」档 404 那部分项目根本不存在,再发一次只会再吃 404。
**P2-2(新):重试轮把根的标题贴到了前置头上**
根因是判「这条是不是根项目自己失败」用了 `plan.request.roots.first()`,而重试那一轮的 plan
来自 `retry_request()`,它的 `roots` 就是上一轮的失败名单——第一个是前置。
现在判据改成 **id 比对**(`Root{id,title}`,id 由 `remember` 一起记下来):
`label_of` 只在这条失败的 id 真的等于原始根 id 时才用根的标题。复审给的那个形状
(roots=["P7dR8mSH","1eAoo2KR"] + root_title="Sodium")现在画 `P7dR8mSH`,不再是
`Sodium (P7dR8mSH)`。
**守门(每条都真跑红过,且在拆完模块的最终代码上复跑)**
- 去掉不兼容档的名字来源 ⇒ `an_incompatible_item_shows_the_project_name_once_it_is_known` 红
(`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`)。
- `label_of` 换回 `roots.first()` ⇒ `the_retry_round_does_not_paste_the_root_title_onto_a_dependency` 红
(`left: ["Sodium (P7dR8mSH)","1eAoo2KR"]`,正是复审实测的那一行)。
- 取名字不降级(`expect`)⇒ `a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything` 红。
**行数**:`plan.rs` 加完这套到 414 行、`install.rs` 到 403 行,都越过 400 硬线 ⇒
把「名字从哪来」整块(`Root`/`Names`/`names_for_incompatible`/`label_of`/`fetch_title`)
纯搬移到 `plan/labels.rs`(111 行):`plan.rs` 336、`install.rs` 388。
346 lines
14 KiB
Rust
346 lines
14 KiB
Rust
//! 内容库的演示数据(`pages_demo` 出图与单测用;产品路径永远走 `Content::from_snapshot`)。
|
||
//!
|
||
//! ## 为什么要环境变量选「哪一屏」
|
||
//!
|
||
//! D30 的验收矩阵要求「四类内容 × 浅/深」加「搜索无结果」「加载器不兼容禁用」两种状态,
|
||
//! 共 10 张。`pages_demo` 的 `--screen=` 只够选到**哪一页**,选不到「同一页的哪一态」;
|
||
//! 而 `pages/harness.rs` 与 `cli.rs` 不在本 mission 的 scope。
|
||
//!
|
||
//! 所以沿用本仓既有的取证开关范式(`ULL_START_ROUTE` / `ULL_APPEARANCE_*` / `ULL_TICK_TRACE`):
|
||
//!
|
||
//! | 变量 | 取值 | 作用 |
|
||
//! |---|---|---|
|
||
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
|
||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` / `retryfail` / `retryok` | 选状态(默认 `ok`;`offline` = M162 的未联网态;`retryfail` = M191 的「有失败项」,`retryok` = M191 的「全部成功」) |
|
||
//!
|
||
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
|
||
|
||
use unlucky_i18n::{Catalog, Key, Lang};
|
||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||
|
||
use super::model::{
|
||
blockers_of, Content, ContentQueryView, Hit, HitFacts, RetryPanel, Status, Target, VersionRow,
|
||
};
|
||
use crate::pages::download::Download;
|
||
|
||
/// M191「有失败项」那一屏:两档各几个项目,面板按档分组。
|
||
///
|
||
/// **走真判据**([`RetryPanel::of`]),不手搓面板字段:出图与产品路径读同一个函数,
|
||
/// 判据一改这条演示就跟着变(否则演示图能一直画着一个已经不存在的面板)。
|
||
fn retry_failure(instance: &str, catalog: Catalog) -> Option<RetryPanel> {
|
||
use unlucky_presenter::dto::{
|
||
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
|
||
};
|
||
let mut progress = InstallProgressView {
|
||
version: "Sodium".to_string(),
|
||
instance: instance.to_string(),
|
||
loader: "Fabric".to_string(),
|
||
stage: InstallStage::Failed,
|
||
files_done: 1,
|
||
files_total: 4,
|
||
..InstallProgressView::new()
|
||
};
|
||
// 三种可显示性各一条,与 host 侧 `plan::label_of` 的三种情况同形:
|
||
// 根项目 = 请求里的标题、下载失败 = 文件名、不兼容档 = 失败路径上现取的项目名。
|
||
progress.retry = vec![
|
||
InstallFailureView::new(
|
||
"AANobbMI",
|
||
"Sodium",
|
||
InstallFailureKind::Unreachable,
|
||
"下载 sodium-fabric-0.6.0.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
|
||
),
|
||
InstallFailureView::new(
|
||
"P7dR8mSH",
|
||
"Entity Culling",
|
||
InstallFailureKind::Incompatible,
|
||
"没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本",
|
||
),
|
||
InstallFailureView::new(
|
||
"1eAoo2KR",
|
||
"fabric-api-0.92.2+1.20.1.jar",
|
||
InstallFailureKind::Unreachable,
|
||
"下载 fabric-api-0.92.2+1.20.1.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
|
||
),
|
||
];
|
||
RetryPanel::of(&progress, catalog)
|
||
}
|
||
|
||
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
|
||
fn targets() -> Vec<Target> {
|
||
vec![
|
||
Target {
|
||
id: "survival".to_string(),
|
||
name: "Survival".to_string(),
|
||
mc_version: "1.20.1".to_string(),
|
||
loader: LoaderKind::Fabric,
|
||
},
|
||
Target {
|
||
id: "modded".to_string(),
|
||
name: "Modded".to_string(),
|
||
mc_version: "1.21.1".to_string(),
|
||
loader: LoaderKind::Forge,
|
||
},
|
||
]
|
||
}
|
||
|
||
/// 四条命中项:**先造 `ContentHit` 再走 `Hit::of`**,保证演示数据与产品路径逐字段同源
|
||
/// (自己拼 `Hit` 的那一版会把「1,200,000 下载量」「Fabric 首字母大写」这类格式化漂开)。
|
||
fn hits(kind: ContentKind, catalog: Catalog, compatible_only: bool) -> Vec<Hit> {
|
||
let rows: &[(&str, &str, &str)] = match kind {
|
||
ContentKind::Modpack => &[
|
||
(
|
||
"Fabulously Optimized",
|
||
"robotkoer",
|
||
"Performance-focused vanilla+ pack",
|
||
),
|
||
("Adrenaline", "Someone", "Lightweight kitchen-sink pack"),
|
||
("Better MC", "Luna", "Adventure pack with quests"),
|
||
("All the Mods 9", "ATMTeam", "Kitchen sink, 400+ mods"),
|
||
],
|
||
ContentKind::Mod => &[
|
||
("Sodium", "jellysquid3", "Modern rendering engine"),
|
||
("Lithium", "jellysquid3", "Game logic optimizations"),
|
||
("Iris Shaders", "coderbot", "Shader support for Fabric"),
|
||
("JEI", "mezz", "Item and recipe viewer"),
|
||
],
|
||
ContentKind::Resourcepack => &[
|
||
(
|
||
"Faithful 32x",
|
||
"Faithful",
|
||
"Double resolution vanilla textures",
|
||
),
|
||
("Stay True", "Fred", "Warm, hand-drawn look"),
|
||
("Classic 3D", "Someone", "3D blocks and items"),
|
||
("Bare Bones", "Someone", "Flat, minimalist textures"),
|
||
],
|
||
ContentKind::Shader => &[
|
||
("Complementary Reimagined", "Emin", "Balanced, high quality"),
|
||
("BSL Shaders", "capttatsu", "Soft colors, strong weather"),
|
||
("Photon", "SixthSurge", "Path-traced look"),
|
||
("MakeUp Ultra Fast", "Someone", "Lightweight shader"),
|
||
],
|
||
};
|
||
|
||
rows.iter()
|
||
.enumerate()
|
||
.map(|(index, (title, author, summary))| {
|
||
// 前两条只支持 1.20.1 + Fabric(能装进 Survival);后两条是 1.21.1 + Forge
|
||
let compatible = compatible_only || index < 2;
|
||
Hit::of(
|
||
&ContentHit {
|
||
id: format!("{}-{index}", kind.slug()),
|
||
slug: format!("{}-{index}", kind.slug()),
|
||
title: (*title).to_string(),
|
||
summary: (*summary).to_string(),
|
||
author: (*author).to_string(),
|
||
downloads: 1_200_000u64.saturating_sub(index as u64 * 111_111),
|
||
updated: format!("2026-0{}-1{index}", index + 1),
|
||
source: if index % 2 == 0 {
|
||
ContentSource::Modrinth
|
||
} else {
|
||
ContentSource::Curseforge
|
||
},
|
||
game_versions: if compatible {
|
||
vec!["1.20.1".to_string()]
|
||
} else {
|
||
vec!["1.21.1".to_string()]
|
||
},
|
||
loaders: if compatible {
|
||
vec!["fabric".to_string()]
|
||
} else {
|
||
vec!["forge".to_string()]
|
||
},
|
||
icon: None,
|
||
},
|
||
catalog,
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 装入中的面板(**复用下载页那块**:`pages::download::install_panel_model` + `install_panel`)。
|
||
fn install_panel(catalog: Catalog) -> Download {
|
||
use unlucky_presenter::dto::{InstallProgressView, InstallStage};
|
||
let progress = InstallProgressView {
|
||
version: "Sodium".to_string(),
|
||
instance: "survival".to_string(),
|
||
loader: "Fabric".to_string(),
|
||
stage: InstallStage::Downloading,
|
||
files_done: 0,
|
||
files_total: 1,
|
||
message: "sodium-fabric-0.6.0.jar".to_string(),
|
||
..InstallProgressView::new()
|
||
};
|
||
crate::pages::download::install_panel_model(&progress, catalog)
|
||
.expect("演示数据的进度是「下载中」,面板必然存在")
|
||
}
|
||
|
||
/// 内建模型(默认 `ok` + 从环境变量读类型/状态)。
|
||
pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||
let catalog = lang.catalog();
|
||
let kind = std::env::var("ULL_CONTENT_KIND")
|
||
.ok()
|
||
.and_then(|value| {
|
||
ContentKind::ALL
|
||
.into_iter()
|
||
.find(|item| item.slug() == value.trim())
|
||
})
|
||
.unwrap_or(fallback_kind);
|
||
let state = std::env::var("ULL_CONTENT_STATE").unwrap_or_else(|_| "ok".to_string());
|
||
|
||
let targets = targets();
|
||
let target = Some("survival".to_string());
|
||
let empty = state == "empty";
|
||
// M162:「未联网」那一屏(与空态分开画)
|
||
let offline = state == "offline";
|
||
// M162:详情通道的版本列表(真实路径来自 presenter 的 `ContentDetail`)
|
||
let demo_versions = if empty || offline {
|
||
Vec::new()
|
||
} else {
|
||
vec![
|
||
VersionRow {
|
||
number: "mc1.20.1-0.5.11".to_string(),
|
||
kind: "release".to_string(),
|
||
file_name: Some("sodium-fabric-0.5.11.jar".to_string()),
|
||
line: catalog.render(
|
||
Key::ContentDetailVersionLine,
|
||
&[
|
||
("number", "mc1.20.1-0.5.11"),
|
||
("kind", "Release"),
|
||
("date", "2025-06-01"),
|
||
],
|
||
),
|
||
},
|
||
VersionRow {
|
||
number: "mc1.20.1-0.5.10".to_string(),
|
||
kind: "beta".to_string(),
|
||
file_name: Some("sodium-fabric-0.5.10.jar".to_string()),
|
||
line: catalog.render(
|
||
Key::ContentDetailVersionLine,
|
||
&[
|
||
("number", "mc1.20.1-0.5.10"),
|
||
("kind", "Beta"),
|
||
("date", "2025-05-20"),
|
||
],
|
||
),
|
||
},
|
||
]
|
||
};
|
||
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
|
||
let installing = state == "install";
|
||
// 空态与未联网态都**没有命中项**:离线时画出列表就是自相矛盾(列表本身就来自网络)
|
||
let list = if empty || offline {
|
||
Vec::new()
|
||
} else {
|
||
hits(kind, catalog, state != "incompatible")
|
||
};
|
||
// 「不兼容」那一屏:选中的那条是 1.21.1 + Forge 的项目,而目标是 1.20.1 + Fabric
|
||
// 「CF 未接通」那一屏:选中的那条来自 CurseForge(文件端点还没接,按钮必须禁用)
|
||
let selected_index = match state.as_str() {
|
||
"incompatible" => 2,
|
||
"cf" => 1,
|
||
_ => 0,
|
||
};
|
||
let selected = list.get(selected_index).map(|hit| hit.id.clone());
|
||
let blockers = blockers_of(
|
||
list.get(selected_index).map(|hit| HitFacts {
|
||
versions: hit.versions.as_slice(),
|
||
loaders: hit.loaders.as_slice(),
|
||
source: hit.source,
|
||
}),
|
||
kind,
|
||
&targets,
|
||
target.as_deref(),
|
||
);
|
||
let target_label = targets
|
||
.iter()
|
||
.find(|item| Some(&item.id) == target.as_ref())
|
||
.map(|item| item.label());
|
||
|
||
// M113: import/export —— 导入导出区自己那一态由 `ULL_CONTENT_PACK` 选
|
||
// (`importing` / `partial` / `conflict` / `export`;不设 = 只有入口)
|
||
let pack = super::packui::demo_pack(lang).0;
|
||
|
||
Content {
|
||
kind,
|
||
query: ContentQueryView {
|
||
source: ContentSource::Modrinth,
|
||
sort: ContentSort::Relevance,
|
||
version: Some("1.20.1".to_string()),
|
||
loader: Some(LoaderKind::Fabric),
|
||
text: if empty {
|
||
"quarkonium".to_string()
|
||
} else {
|
||
String::new()
|
||
},
|
||
},
|
||
hits: list,
|
||
selected,
|
||
status: if offline {
|
||
Status::Offline("connect timed out after 10s".to_string())
|
||
} else if empty {
|
||
Status::Empty
|
||
} else {
|
||
Status::Ready
|
||
},
|
||
total: if empty { 0 } else { 1_284 },
|
||
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
|
||
has_more: !empty,
|
||
curseforge: false,
|
||
targets,
|
||
target: target.clone(),
|
||
target_label,
|
||
blockers,
|
||
panel: if installing {
|
||
Some(install_panel(catalog))
|
||
} else {
|
||
None
|
||
},
|
||
// M191:`retryfail` 画「有失败项」,`retryok` 画「全部成功」——后者走同一条判据,
|
||
// 得到 `None`(连按钮都不渲染)。
|
||
retry: match state.as_str() {
|
||
"retryfail" => retry_failure("survival", catalog),
|
||
_ => None,
|
||
},
|
||
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
|
||
available_versions: demo_versions,
|
||
versions_note: None,
|
||
draft: if empty {
|
||
"quarkonium".to_string()
|
||
} else {
|
||
String::new()
|
||
},
|
||
pack,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// 「有失败项」那一屏的两档走**真判据**(`RetryPanel::of`),不是手搓的面板字段。
|
||
#[test]
|
||
fn the_failure_screen_shows_both_tiers() {
|
||
let panel = retry_failure("survival", Lang::En.catalog()).expect("演示必须有面板");
|
||
assert_eq!(panel.total, 3);
|
||
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
|
||
assert_eq!(
|
||
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable),
|
||
Some(
|
||
[
|
||
// 根项目:名字来自请求标题
|
||
"Sodium (AANobbMI)".to_string(),
|
||
// 下载失败:名字来自文件名
|
||
"fabric-api-0.92.2+1.20.1.jar (1eAoo2KR)".to_string(),
|
||
]
|
||
.as_slice()
|
||
)
|
||
);
|
||
// 不兼容档:失败路径上现取的项目名(不是 id)
|
||
assert_eq!(
|
||
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Incompatible),
|
||
Some(["Entity Culling (P7dR8mSH)".to_string()].as_slice())
|
||
);
|
||
}
|
||
}
|