feat(download): M94 下载页换成真组件 + 真快照 + 真指令(PAGEDL)
页面(crates/app-iced/src/pages/download*):
- Tabs(分类)/ ListItem(版本行)/ filled 安装按钮 / progress / EmptyState / divider
全部取自 components/ 的真组件;页头动作换成 buttons::text
- 数据只吃 pages::download::Download(app/view.rs 由 AppState 快照转,installed_only 与
download_query 照用);页面自己不留业务状态,交互只发意图
- 状态:加载(不确定进度 + 文案)/ 空(EmptyState + 刷新 CTA)/ 有数据 / 安装中 / 安装失败
(失败给可读文案 + 「打开日志」出口)
接线(追加式):
- pages::Message 加 SelectVersion / InstallVersion / CancelInstall / SetCategory
- app/update.rs 翻成 AppCommand::{InstallVersion, CancelInstall, ListVersions}
(SelectVersion 只改视图模型;「刷新」= 当前分类重新拉,全部走 RefreshAll)
- pages::Model 加 download 字段
文案:28 个 Download* key 进中英两套 catalog(一行一个 key 的全语言);页面零中文字面量,
core 的中文阶段串(InstallStage::label / message)一律不进界面
i18n 结构:catalog.rs 的既有测试模块原样外置到 catalog/tests.rs(392 行 → 282 行,
逐字未改),新 key 才装得下 400 行硬上限
测试:app-iced 276 + i18n 23 全绿;clippy -D warnings 与 fmt --check 干净
This commit is contained in:
1 parent
b3f99652bb
commit
57e8380b24
14 files changed
+1633
-247
No files matched your search
@@ -0,0 +1,384 @@
|
||||
//! 下载页的判据:**快照 → 模型 → 控件树**三段各自的断言。
|
||||
//!
|
||||
//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由报告里的
|
||||
//! 四张图与数值表负责(同 `pages/tests.rs` 的口径)。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
AncientKind, AppEvent, CoreEvent, InstallProgressView, InstallStage, VersionCategory,
|
||||
VersionKind, VersionSummary,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
|
||||
use super::super::Message;
|
||||
use super::model::{Category, Download};
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
fn version(id: &str, kind: VersionKind, installed: bool) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with(versions: Vec<VersionSummary>) -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions)));
|
||||
state
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 版面(M84 的三条,M94 一条没动)
|
||||
|
||||
/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。
|
||||
///
|
||||
/// 抓得住:`layout` 把下载页切到两列、或把正文列放过 424。
|
||||
/// 抓不住:页面内部又套了一层更宽的容器(那要看真像素,见报告的量测表)。
|
||||
#[test]
|
||||
fn download_is_single_column_at_every_width() {
|
||||
for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] {
|
||||
let metrics = frame_metrics(PaneKit::Single, width);
|
||||
assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列");
|
||||
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
|
||||
assert_eq!(metrics.column, content_column_width(width));
|
||||
}
|
||||
}
|
||||
|
||||
/// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。
|
||||
#[test]
|
||||
fn extra_width_goes_to_margin_not_to_the_column() {
|
||||
let metrics = frame_metrics(PaneKit::Single, 1180.0);
|
||||
assert_eq!(metrics.column, measure_cap_px());
|
||||
assert!(
|
||||
metrics.available - metrics.column >= 400.0,
|
||||
"1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)"
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 快照 → 模型
|
||||
|
||||
/// 真快照进得来:行数、分类、选中项、已装标记全部来自 `AppState`。
|
||||
///
|
||||
/// 抓得住:页面自己造数据、或把 `installed` / `kind` 丢了。
|
||||
/// 抓不住:快照字段在 `app/view.rs` 被漏接(那由 `app/update.rs` 的接线测试兜)。
|
||||
#[test]
|
||||
fn the_snapshot_becomes_the_page_model() {
|
||||
let mut state = state_with(vec![
|
||||
version("1.21.9", VersionKind::Release, false),
|
||||
version("1.21.8", VersionKind::Release, true),
|
||||
]);
|
||||
state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some(
|
||||
InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
loader: "Vanilla".to_string(),
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 128,
|
||||
files_total: 512,
|
||||
speed_bps: 1_258_291,
|
||||
eta_secs: 75,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
))));
|
||||
|
||||
let model = Download::from_snapshot(&state, Lang::En.catalog());
|
||||
|
||||
assert_eq!(model.rows.len(), 2);
|
||||
assert_eq!(model.rows[0].id, "1.21.9");
|
||||
assert!(model.rows[1].installed, "快照说装了就是装了");
|
||||
assert!(!model.rows[0].installed);
|
||||
assert_eq!(
|
||||
model.category,
|
||||
Category::Release,
|
||||
"整份列表都是正式版 → 反推出正式版分类"
|
||||
);
|
||||
assert_eq!(
|
||||
model.selected.as_deref(),
|
||||
Some("1.21.9"),
|
||||
"正在安装的版本 = 选中行"
|
||||
);
|
||||
assert!(model.loaded, "有版本 = 已加载");
|
||||
|
||||
let install = model.install.expect("安装进度要转进来");
|
||||
assert!(install.running(), "Downloading 还没收尾");
|
||||
assert!(!install.failed());
|
||||
assert_eq!(install.detail, "128/512 files");
|
||||
assert_eq!(
|
||||
install.rate.as_deref(),
|
||||
Some("1.2 MB/s · 1m15s left"),
|
||||
"速率与剩余时间走 key,不搬 core 的中文串"
|
||||
);
|
||||
}
|
||||
|
||||
/// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用。
|
||||
///
|
||||
/// 抓得住:页面把 presenter 的过滤条件丢了(用户设了「仅已装」,页面照画全部)。
|
||||
/// 抓不住:过滤词大小写之外的语言学规则(那属于 presenter 的搜索口径)。
|
||||
#[test]
|
||||
fn presenter_filters_still_apply() {
|
||||
let mut state = state_with(vec![
|
||||
version("1.21.9", VersionKind::Release, false),
|
||||
version("1.21.8", VersionKind::Release, true),
|
||||
version("1.20.4", VersionKind::Release, true),
|
||||
]);
|
||||
|
||||
let installed_only = {
|
||||
state.installed_only = true;
|
||||
Download::from_snapshot(&state, Lang::En.catalog())
|
||||
};
|
||||
assert_eq!(installed_only.rows.len(), 2, "仅已装");
|
||||
|
||||
let queried = {
|
||||
state.installed_only = false;
|
||||
state.download_query = " 1.21 ".to_string();
|
||||
Download::from_snapshot(&state, Lang::En.catalog())
|
||||
};
|
||||
assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个");
|
||||
assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21")));
|
||||
}
|
||||
|
||||
/// 空快照 = 加载态;过滤掉所有行 = 空态。两者都**不许**是白屏。
|
||||
///
|
||||
/// 抓得住:把「还没拉到」画成「没有版本」(谎报空态),或反过来。
|
||||
/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。
|
||||
#[test]
|
||||
fn loading_and_empty_are_different_states() {
|
||||
let loading = Download::from_snapshot(&AppState::new(), Lang::En.catalog());
|
||||
assert!(loading.is_loading() && !loading.is_empty());
|
||||
|
||||
let mut state = state_with(vec![version("1.21.9", VersionKind::Release, false)]);
|
||||
state.download_query = "no-such-version".to_string();
|
||||
let empty = Download::from_snapshot(&state, Lang::En.catalog());
|
||||
assert!(!empty.is_loading(), "列表拉到了,不是加载中");
|
||||
assert!(empty.is_empty(), "过滤后没有行 = 空态");
|
||||
}
|
||||
|
||||
/// 分类反推:混合 = 全部,远古 = 远古(不看子类)。
|
||||
#[test]
|
||||
fn the_category_is_derived_from_the_list() {
|
||||
let mixed = vec![
|
||||
version("1.21.9", VersionKind::Release, false),
|
||||
version("1.21.9-pre3", VersionKind::Snapshot, false),
|
||||
];
|
||||
assert_eq!(Category::of(&mixed), Category::All);
|
||||
assert_eq!(
|
||||
Category::of(&[version("x", VersionKind::Snapshot, false)]),
|
||||
Category::Snapshot
|
||||
);
|
||||
assert_eq!(
|
||||
Category::of(&[version(
|
||||
"rd-132211",
|
||||
VersionKind::Ancient(AncientKind::OldBeta),
|
||||
false
|
||||
)]),
|
||||
Category::Ancient
|
||||
);
|
||||
assert_eq!(Category::of(&[]), Category::All);
|
||||
}
|
||||
|
||||
/// 分类 chip 派的是**真指令**:`VersionCategory` 直接喂给 `AppCommand::ListVersions`。
|
||||
///
|
||||
/// 抓得住:chip 点到不存在的指令、或把 `All` 也派出去(那会白拉一次列表)。
|
||||
/// 抓不住:后端是否真按分类回列表(presenter/host 的测试负责)。
|
||||
#[test]
|
||||
fn category_commands_are_real() {
|
||||
assert_eq!(Category::All.command(), None, "全部 = 画现有快照,不派指令");
|
||||
assert_eq!(Category::Release.command(), Some(VersionCategory::Release));
|
||||
assert_eq!(
|
||||
Category::Snapshot.command(),
|
||||
Some(VersionCategory::Snapshot)
|
||||
);
|
||||
assert_eq!(
|
||||
Category::Ancient.command(),
|
||||
Some(VersionCategory::Ancient(AncientKind::Classic)),
|
||||
"远古版先拉 classic(四子类同屏要 presenter 支持,已登记 finding)"
|
||||
);
|
||||
assert!(Category::Ancient.matches(VersionKind::Ancient(AncientKind::Unlisted)));
|
||||
assert!(!Category::Release.matches(VersionKind::Snapshot));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 文案(i18n)
|
||||
|
||||
/// 中英两套目录都能把这一页的每个 key 求出来(缺译会露出 `⟪missing⟫`)。
|
||||
///
|
||||
/// 「这一页的 key」按 `Download` 前缀取,不手写清单:清单会漏、会在下一次加 key 时不同步,
|
||||
/// 而前缀遍历顺手还守住了「不许把 key 悄悄删掉」(条数下限)。
|
||||
///
|
||||
/// 抓得住:新增 key 只写了一种语言(另一套会显示 `⟪missing⟫`)。
|
||||
/// 抓不住:译文质量,以及**没有用到的** key(那是 `catalog` 自己的完整性测试)。
|
||||
#[test]
|
||||
fn every_download_key_exists_in_both_catalogs() {
|
||||
let download_keys: Vec<Key> = Key::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| key.name().starts_with("Download"))
|
||||
.collect();
|
||||
assert!(download_keys.len() >= 25, "下载页的 key 不该被悄悄删掉");
|
||||
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let catalog = lang.catalog();
|
||||
for key in &download_keys {
|
||||
let text = catalog.text(*key);
|
||||
assert_ne!(text, unlucky_i18n::catalog::MISSING, "{key:?} 缺译");
|
||||
assert!(!text.is_empty(), "{key:?} 是空串");
|
||||
}
|
||||
assert!(catalog.missing().is_empty(), "内置目录不许有缺译");
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。
|
||||
///
|
||||
/// 抓得住:页面里又出现硬编码英文/中文字面量、或把 `InstallStage::label()` / `progress.message`
|
||||
/// (core 的中文串)画到界面上。
|
||||
/// 抓不住:目录里那条译文本身写错了。
|
||||
#[test]
|
||||
fn the_model_speaks_the_catalog_language() {
|
||||
let versions = vec![version("1.21.9", VersionKind::Release, false)];
|
||||
let state = state_with(versions);
|
||||
|
||||
let en = Download::from_snapshot(&state, Lang::En.catalog());
|
||||
let zh = Download::from_snapshot(&state, Lang::ZhCn.catalog());
|
||||
|
||||
assert_eq!(en.rows[0].supporting, "Release · released 2026-09-20");
|
||||
assert_eq!(zh.rows[0].supporting, "正式版 · 发布于 2026-09-20");
|
||||
|
||||
let progress = InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Planning,
|
||||
message: "正在解析版本清单…".to_string(),
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
let install_en = super::model::InstallView::of(&progress, Lang::En.catalog());
|
||||
assert_eq!(install_en.stage_label, "Planning");
|
||||
assert_eq!(install_en.detail, "Waiting for the first file…");
|
||||
assert!(
|
||||
!install_en.stage_label.contains('解'),
|
||||
"core 的中文阶段串不许直接上屏"
|
||||
);
|
||||
assert!(
|
||||
!install_en.detail.contains('正'),
|
||||
"`InstallProgressView::message` 是中文业务串,页面不许画它"
|
||||
);
|
||||
|
||||
let install_zh = super::model::InstallView::of(&progress, Lang::ZhCn.catalog());
|
||||
assert_eq!(install_zh.stage_label, "解析版本");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 控件树
|
||||
|
||||
/// 五个状态都能构建出控件树(分支走到、不 panic、消息类型对得上)。
|
||||
///
|
||||
/// 抓得住:某个状态分支漏了返回值、或消息类型接错(编译期就红);空态/加载态忘了给内容。
|
||||
/// 抓不住:**画出来长什么样**——像素由报告里的四张图负责,这条只保证「有树」。
|
||||
#[test]
|
||||
fn every_state_builds_a_tree() {
|
||||
let ui = ui();
|
||||
let metrics = frame_metrics(PaneKit::Single, 1180.0);
|
||||
let catalog = Lang::En.catalog();
|
||||
|
||||
let mut loading = Download::loading();
|
||||
loading.selected = None;
|
||||
|
||||
let empty = Download {
|
||||
loaded: true,
|
||||
..Download::loading()
|
||||
};
|
||||
|
||||
let ready = Download {
|
||||
rows: vec![super::model::VersionRow::of(
|
||||
&version("1.21.9", VersionKind::Release, false),
|
||||
catalog,
|
||||
)],
|
||||
category: Category::Release,
|
||||
selected: Some("1.21.9".to_string()),
|
||||
install: None,
|
||||
loaded: true,
|
||||
};
|
||||
|
||||
let installing = Download {
|
||||
install: Some(super::model::InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Downloading,
|
||||
files_done: 128,
|
||||
files_total: 512,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
catalog,
|
||||
)),
|
||||
..ready.clone()
|
||||
};
|
||||
|
||||
let failed = Download {
|
||||
install: Some(super::model::InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
catalog,
|
||||
)),
|
||||
..ready.clone()
|
||||
};
|
||||
|
||||
for model in [loading, empty, ready, installing, failed] {
|
||||
let _: iced::Element<'_, Message> = super::view(&ui, metrics, &model, Lang::En);
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装失败的模型:可读文案 + 出口都有(不静默)。
|
||||
///
|
||||
/// 抓得住:失败态被画成「已完成」或什么都不给。
|
||||
/// 抓不住:失败原因的具体内容(core 只给中文 `message`,页面按规矩不画它,见模块文档)。
|
||||
#[test]
|
||||
fn a_failed_install_stays_visible() {
|
||||
let model = super::model::InstallView::of(
|
||||
&InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
},
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(model.failed());
|
||||
assert!(model.terminal());
|
||||
assert!(!model.running(), "失败之后不再给「取消安装」");
|
||||
assert_eq!(model.stage_label, "Install failed");
|
||||
}
|
||||
|
||||
/// 演示数据自己也得守规矩(它是出图的基线)。
|
||||
///
|
||||
/// 抓得住:演示数据里混进中文字面量、或行数与分类对不上。
|
||||
/// 抓不住:图上好不好看。
|
||||
#[test]
|
||||
fn the_demo_data_is_sane() {
|
||||
let catalog = Lang::En.catalog();
|
||||
let demo = super::demo(catalog);
|
||||
assert!(demo.loaded);
|
||||
assert_eq!(demo.rows.len(), 6);
|
||||
assert_eq!(demo.category, Category::All, "混合列表没有单个分类该亮");
|
||||
assert!(
|
||||
demo.rows
|
||||
.iter()
|
||||
.all(|row| row.supporting.contains("released")),
|
||||
"英文目录下的副文案走英文 key"
|
||||
);
|
||||
assert!(
|
||||
demo.rows.iter().all(|row| !row.supporting.contains('版')),
|
||||
"英文目录下不许出现中文类型词"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user