复审 r1(p2-5items / fix-then-merge)的五条可达问题,逐条落点: - **P2-1** 搜索命中数与主版本桶不再自相矛盾:`group::active_bucket` 让第二级在搜索期间 **自动放宽到「全部」**(选择本身留在模型里,清空搜索后回来),侧栏点亮「全部版本」⇒ 没有幽灵选中; `rebuild_after_query` 回填时校验桶仍存在(新桶列表里没有的选择直接丢弃)。 判据:`a_search_hit_never_lands_on_an_empty_list`(4 分类 × 5 桶值 × 4 查询词的矩阵)+ 出图 11 - **P2-2** chips 档的主版本行套**横向滚动**(显式滚动条),竖栏与 chips 吃同一份 `major_entries`; 22 桶夹具断言最后一个桶 `1.0` 在列表里,出图 12 看得到滚动条 - **P2-3** 只做搜索这条路径(`rebuild_after_query` + 校验 + app 层测试);`app/state.rs::refresh_view` 的全量修法**留到 M105 之后**(不在 scope,且 M105 正改同一片文件)——报告里明写 - **P2-4** `1 matches` 修掉:新增 `VersionSearchHitsOne` / `VersionSearchElsewhereOne` 两条单数 key, 「在别处」用独立标题(不再拿命中数当标题,同屏一个数只报一次) - **P2-5** 搜索链路补 4 条 **app 层端到端**(键入 → 快照 → 重建 → 列表);变异自证: 删 `self.state.download_query = query` ⇒ 2 条红;`from_snapshot` 忽略 query ⇒ 8 条红; 去掉 `active_bucket` 的放宽 ⇒ 4 条红 - 顺带:64 宽图标条每项挂 tooltip(复审建议);`install.rs` 那段自相矛盾的文档订正 门禁:fmt 0 / app-iced lib 431 passed / workspace --exclude unlucky-app 全绿(见报告)
302 lines
14 KiB
Rust
302 lines
14 KiB
Rust
//! 下载页的**演示数据**(截图 harness 与单测的输入,产品路径不读)。
|
||
//!
|
||
//! 与 `pages/instances/data.rs` 同一条纪律:演示条目是**页面侧常量**,不是业务 DTO——
|
||
//! 于是核心 DTO 每改一次字段,基线图不会跟着漂。真页面的数据只走
|
||
//! [`Download::from_snapshot`](super::model::Download::from_snapshot)。
|
||
//!
|
||
//! 演示条目本身也是一步「DTO → 视图模型」的真转换([`VersionRow::of`](super::model::VersionRow)、
|
||
//! [`InstallView::of`](super::model::InstallView)):图上看到的副文案 / 进度行就是产品路径
|
||
//! 那条转换的产物,不是另写一套拼串。
|
||
//!
|
||
//! ## 为什么要一个环境变量
|
||
//!
|
||
//! 状态矩阵(加载 / 空 / 有数据 / 安装中 / 安装失败)都要出图,而 harness 的
|
||
//! `--screen` 只选页面、选不了页面状态:
|
||
//!
|
||
//! ```text
|
||
//! ULL_DL_DEMO=empty cargo run -p unlucky-app-iced --example pages_demo -- \
|
||
//! --screen=download --screenshot=out.png --screenshot-theme=light --software-renderer
|
||
//! ```
|
||
//!
|
||
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` /
|
||
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` /
|
||
//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)/
|
||
//! **`category-ancient` / `search` / `search-major` / `search-empty` / `search-elsewhere`**
|
||
//! (M107 的两级侧栏与搜索取样点)。
|
||
//!
|
||
//! M107 那几个取样点**都走产品那条真转换**(`Download::from_snapshot`,查询词写在快照的
|
||
//! `download_query` 里):图证明的是产品路径的行为,不是演示的特权。
|
||
//!
|
||
//! `category-release` / `done` / `done-next` 都**不手搓结果**,而是拿一份 `AppState` 跑
|
||
//! [`Download::from_snapshot`]:图证明的是产品路径的行为(分类本地过滤、装完后面板让回列表),
|
||
//! 不是演示的特权。
|
||
//!
|
||
//! `done` / `done-next` 是「**刚装完**」的两个采样点:它们不手搓模型,而是拿一份
|
||
//! `AppState`(版本列表 + `InstallStage::Done`)跑 [`Download::from_snapshot`],所以图上看到的是
|
||
//! 产品路径的真行为(复审 p2:`Done` 之后面板必须让回列表,否则第二个版本装不了)。
|
||
//! **只有 harness 与演示走这里**:产品路径由 `app/view.rs` 从 presenter 快照建模型。
|
||
|
||
use unlucky_presenter::dto::{
|
||
AncientKind, InstallProgressView, InstallStage, VersionKind, VersionSummary,
|
||
};
|
||
|
||
use unlucky_presenter::dto::{AppEvent, CoreEvent};
|
||
use unlucky_presenter::state::AppState;
|
||
|
||
use unlucky_i18n::Catalog;
|
||
|
||
use super::model::{Category, Download, InstallView, VersionRow};
|
||
use super::options::{InstallDraft, Loader};
|
||
|
||
/// 取证开关的名字([`demo`] 读它)。
|
||
pub const DEMO_STATE_ENV: &str = "ULL_DL_DEMO";
|
||
|
||
/// 演示模型:按 [`DEMO_STATE_ENV`] 选状态,缺省是「有数据」。
|
||
pub fn demo(catalog: Catalog) -> Download {
|
||
match std::env::var(DEMO_STATE_ENV).ok().as_deref() {
|
||
Some("loading") => Download::loading(),
|
||
Some("empty") => Download {
|
||
loaded: true,
|
||
..Download::loading()
|
||
},
|
||
Some("installing") => Download {
|
||
install: Some(install(InstallStage::Downloading, 0.42, catalog)),
|
||
selected: Some("1.21.9".to_string()),
|
||
..ready(catalog)
|
||
},
|
||
// 同一状态的第二个采样点:进度条随**事件**推进(不是动画),出图时两帧对照
|
||
Some("installing-late") => Download {
|
||
install: Some(install(InstallStage::Downloading, 0.8, catalog)),
|
||
selected: Some("1.21.9".to_string()),
|
||
..ready(catalog)
|
||
},
|
||
// M105:安装选项区三态(未选加载器 / 已选 Fabric / 不兼容组合)。
|
||
// 三条都走**产品那条真转换**(`from_snapshot` + 草稿),图里看到的是页面真会画的东西。
|
||
Some("options") => options(catalog, "1.21.9", Loader::Vanilla, None),
|
||
Some("options-fabric") => options(
|
||
catalog,
|
||
"1.21.9",
|
||
Loader::Fabric,
|
||
Some(vec!["0.16.5", "0.16.4", "0.15.11"]),
|
||
),
|
||
// NeoForge 最早只到 1.20.1,远古版(列表里的 `rd-132211`)根本没有构建 ⇒ 选中即报原因
|
||
Some("options-incompat") => options(catalog, "rd-132211", Loader::NeoForge, None),
|
||
Some("selected") => Download {
|
||
selected: Some("1.21.9".to_string()),
|
||
..ready(catalog)
|
||
},
|
||
Some("failed") => Download {
|
||
install: Some(install(InstallStage::Failed, 0.0, catalog)),
|
||
selected: Some("1.21.9".to_string()),
|
||
..ready(catalog)
|
||
},
|
||
// 装完之后:走**产品那条真转换**(见 `finished`),图里看到的就是页面真会画的东西
|
||
Some("done") => finished(catalog, None),
|
||
// 装完之后再点下一个版本:面板必须重新给出实心「安装」
|
||
Some("done-next") => finished(catalog, Some("1.21.9-pre3")),
|
||
// 安装进行中、但列表被过滤空:面板要跟着占位态一起在(复审 p3 的边角)
|
||
// 分类筛选(本页本地过滤:真 host 丢弃 ListVersions 的分类参数)——混合清单里只留正式版
|
||
Some("category-release") => category(catalog, Category::Release),
|
||
// M107 的两级侧栏 + 搜索:这三种状态**都走产品那条真转换**(from_snapshot),
|
||
// 图里看到的是页面真会画的东西,不是演示的特权。
|
||
Some("category-ancient") => searched(catalog, Category::Ancient, ""),
|
||
Some("search") => searched(catalog, Category::All, "1.20"),
|
||
Some("search-major") => majored(searched(catalog, Category::All, ""), "1.21"),
|
||
Some("search-empty") => searched(catalog, Category::All, "zzz"),
|
||
Some("search-elsewhere") => searched(catalog, Category::Release, "24w"),
|
||
// 复审 P2-1 的取样点:**选中 1.21 桶的同时搜 1.20**。修好之后列表里是 2 行命中、
|
||
// 侧栏点亮「全部版本」(第二级在搜索期间放宽),不会出现「2 个匹配 + 没有版本」。
|
||
Some("search-over-bucket") => majored(searched(catalog, Category::All, "1.20"), "1.21"),
|
||
// 复审 P2-2 的取样点:**22 个主版本桶**(MOJANG 清单里 `Release` 就是这个量级)。
|
||
// chips 档一行放不下 ⇒ 主版本行必须能横向滚,否则后面的桶永远点不到。
|
||
Some("many-buckets") => many(catalog),
|
||
Some("installing-empty") => Download {
|
||
install: Some(install(InstallStage::Downloading, 0.42, catalog)),
|
||
..Download::loading()
|
||
},
|
||
_ => ready(catalog),
|
||
}
|
||
}
|
||
|
||
/// 有数据:八行(四条正式、两条预览、两条远古),覆盖四种版本形态与三个主版本桶。
|
||
///
|
||
/// 不复用 `mock_data::mock_versions()`:演示数据是**出图的基线**,得比 mock 稳定
|
||
/// (mock 随 P3 的假数据一起漂)。
|
||
fn ready(catalog: Catalog) -> Download {
|
||
let versions = demo_versions();
|
||
let rows: Vec<VersionRow> = versions
|
||
.iter()
|
||
.map(|version| VersionRow::of(version, catalog))
|
||
.collect();
|
||
|
||
Download {
|
||
// 混合分类 → 没有单个 chip 该亮(这正是 `Category::of` 的反推结果)
|
||
category: Category::of(&versions),
|
||
buckets: super::group::buckets(&rows),
|
||
hits: rows.len(),
|
||
rows,
|
||
selected: None,
|
||
options: None,
|
||
install: None,
|
||
loaded: true,
|
||
query: String::new(),
|
||
selected_major: None,
|
||
type_rail_folded: false,
|
||
}
|
||
}
|
||
|
||
/// 「用户选了版本、正在填安装选项」的演示(M105)。
|
||
///
|
||
/// 走真转换:`AppState`(版本列表 + 可选的加载器版本缓存)+ 草稿 → [`Download::from_snapshot`]。
|
||
/// 于是图上「默认取最新加载器版本」「不兼容给具名原因」「确认键禁用」都是产品行为,不是演示特权。
|
||
fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option<Vec<&str>>) -> Download {
|
||
let mut state = AppState::new();
|
||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||
demo_versions(),
|
||
)));
|
||
if let Some(items) = cached {
|
||
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
|
||
kind: loader.kind(),
|
||
mc_version: version.to_string(),
|
||
result: Ok(items.into_iter().map(str::to_string).collect()),
|
||
}));
|
||
}
|
||
let draft = InstallDraft {
|
||
selected: Some(version.to_string()),
|
||
loader,
|
||
..InstallDraft::default()
|
||
};
|
||
Download::from_snapshot(&state, Category::All, &draft, catalog)
|
||
}
|
||
|
||
/// 22 个主版本桶(`1.21 … 1.0`)的清单:chips 档的横向滚动取证用。
|
||
fn many(catalog: Catalog) -> Download {
|
||
let versions = (0..=21)
|
||
.rev()
|
||
.map(|minor| VersionSummary {
|
||
id: format!("1.{minor}.4"),
|
||
kind: VersionKind::Release,
|
||
released: "2026-09-20".to_string(),
|
||
installed: false,
|
||
})
|
||
.collect();
|
||
|
||
let mut state = AppState::new();
|
||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions)));
|
||
Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog)
|
||
}
|
||
|
||
/// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。
|
||
///
|
||
/// 四种形态各有样本:`1.21.9`(正式)、`1.21.9-pre3`(预发布)、`24w14a`(周更)、
|
||
/// `rd-132211` / `a1.2.6`(远古,没有主版本)。
|
||
fn demo_versions() -> Vec<VersionSummary> {
|
||
fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary {
|
||
VersionSummary {
|
||
id: id.to_string(),
|
||
kind,
|
||
released: released.to_string(),
|
||
installed,
|
||
}
|
||
}
|
||
|
||
vec![
|
||
summary("1.21.9", VersionKind::Release, "2026-09-20", false),
|
||
summary("1.21.8", VersionKind::Release, "2026-07-11", true),
|
||
summary("1.20.4", VersionKind::Release, "2023-12-07", true),
|
||
summary("1.20.1", VersionKind::Release, "2023-06-12", false),
|
||
summary("1.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false),
|
||
summary("24w14a", VersionKind::Snapshot, "2024-04-03", false),
|
||
summary(
|
||
"rd-132211",
|
||
VersionKind::Ancient(AncientKind::Classic),
|
||
"2009-05-13",
|
||
false,
|
||
),
|
||
summary(
|
||
"a1.2.6",
|
||
VersionKind::Ancient(AncientKind::OldAlpha),
|
||
"2010-08-11",
|
||
false,
|
||
),
|
||
]
|
||
}
|
||
|
||
/// 「刚装完一个版本」的演示:**走产品那条真转换**(`AppState` → `Download::from_snapshot`)。
|
||
///
|
||
/// 为什么不用手搓模型:本轮复审的 bug 正是「`Done` 之后 `install` 从不清」——手搓一个
|
||
/// `install: None` 的模型会让图**假绿**(图上好看,真路径照样卡死)。这里真的走一遍快照转换,
|
||
/// 于是图能证明「装完之后面板让回列表」是产品路径的行为,不是演示的特权。
|
||
///
|
||
/// `select_next`:演「用户接着点了下一个版本」,面板应当重新给出实心「安装」。
|
||
fn finished(catalog: Catalog, select_next: Option<&str>) -> Download {
|
||
let mut state = AppState::new();
|
||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||
demo_versions(),
|
||
)));
|
||
state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some(
|
||
InstallProgressView {
|
||
version: "1.21.8".to_string(),
|
||
stage: InstallStage::Done,
|
||
files_done: 512,
|
||
files_total: 512,
|
||
..InstallProgressView::new()
|
||
},
|
||
))));
|
||
|
||
let mut model =
|
||
Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog);
|
||
model.selected = select_next.map(str::to_string);
|
||
model
|
||
}
|
||
|
||
/// 「用户点了某个分类」的演示:走真转换(`from_snapshot` 的分类过滤),不手搓过滤结果。
|
||
fn category(catalog: Catalog, wanted: Category) -> Download {
|
||
let mut state = AppState::new();
|
||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||
demo_versions(),
|
||
)));
|
||
Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog)
|
||
}
|
||
|
||
/// 「用户在搜索框里敲了几个字符」的演示:查询词写进**快照字段**(产品路径就是这么活的)。
|
||
fn searched(catalog: Catalog, wanted: Category, query: &str) -> Download {
|
||
let mut state = AppState::new();
|
||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
|
||
demo_versions(),
|
||
)));
|
||
state.download_query = query.to_string();
|
||
Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog)
|
||
}
|
||
|
||
/// 「用户点了第二级侧栏里的一个主版本桶」的演示(纯视图态,与 `on_download` 的落点一致)。
|
||
fn majored(mut model: Download, key: &str) -> Download {
|
||
model.selected_major = Some(key.to_string());
|
||
model
|
||
}
|
||
|
||
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
|
||
fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
|
||
let total = 512;
|
||
let done = (f64::from(ratio) * total as f64) as u64;
|
||
let failed = matches!(stage, InstallStage::Failed);
|
||
|
||
InstallView::of(
|
||
&InstallProgressView {
|
||
version: "1.21.9".to_string(),
|
||
instance: "1.21.9".to_string(),
|
||
// 演示也**不**搬 core 的中文串:`loader` 字段这里给的是结构化徽标的英文名
|
||
loader: "Vanilla".to_string(),
|
||
stage,
|
||
files_done: if failed { 137 } else { done },
|
||
files_total: total,
|
||
bytes_done: 0,
|
||
bytes_total: 0,
|
||
speed_bps: if failed { 0 } else { 1_258_291 },
|
||
eta_secs: if failed { 0 } else { 75 },
|
||
message: String::new(),
|
||
},
|
||
catalog,
|
||
)
|
||
}
|