Merge branch 'feat/p5-download-page-real-components-and-ins'
This commit is contained in:
commit
c0396544c5
18 files changed
+2119
-247
No files matched your search
@@ -258,7 +258,10 @@ impl App {
|
||||
///
|
||||
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
|
||||
pub(super) fn refresh_view(&mut self) {
|
||||
self.view = super::view::model(&self.state, self.width, self.options.force_state);
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
self.view = super::view::model(&self.state, self.width, self.options.force_state, category);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ use super::message::{route_of, Message};
|
||||
use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
|
||||
use super::CAPTURE_AT_FRAME;
|
||||
|
||||
mod download;
|
||||
|
||||
impl App {
|
||||
/// 一条消息进来,改状态并返回后续任务。
|
||||
pub(super) fn update(&mut self, message: Message) -> Task<Message> {
|
||||
@@ -275,6 +277,11 @@ impl App {
|
||||
|
||||
/// 页面消息:导航走路由栈,动作翻成 `AppCommand`。
|
||||
fn on_page(&mut self, message: pages::Message) {
|
||||
// 下载页的四支(含「刷新下载源」)在 `update/download.rs`:本文件 M93 合入后已贴 400 行
|
||||
if self.on_download(&message) {
|
||||
return;
|
||||
}
|
||||
|
||||
match message {
|
||||
pages::Message::Navigate(route) => {
|
||||
self.state.navigate(route);
|
||||
@@ -288,6 +295,8 @@ impl App {
|
||||
pages::Message::Action(action) => {
|
||||
eprintln!("[page] 占位动作 {action:?} pressed(没有对应 AppCommand)");
|
||||
}
|
||||
// 下载页四支 +「刷新下载源」已由上面的 `on_download` 消化,这里只是穷尽性兜底
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,3 +392,6 @@ fn env_start_route() -> Option<Route> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_wiring;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 下载页在 app 层的那几支(M94)。
|
||||
//!
|
||||
//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞
|
||||
//! 四个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
|
||||
//! mission scope)。
|
||||
//!
|
||||
//! ## 四支各自的纪律
|
||||
//!
|
||||
//! | 消息 | 落点 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`pages::Message::SelectVersion`] | **只改视图模型** | 选中是页面内状态,不是业务命令 |
|
||||
//! | [`pages::Message::InstallVersion`] | `AppCommand::InstallVersion` | 真指令,回事件后才画进度 |
|
||||
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 同上(host 侧对没在跑的任务是 no-op) |
|
||||
//! | [`pages::Message::SetCategory`] | 写分类 → 重建 → `AppCommand::ListVersions` | 见下 |
|
||||
//!
|
||||
//! [「刷新下载源」](pages::Action::RefreshSources) 也在这里:它不是占位动作,具体分类 → 重拉该分类、
|
||||
//! 「全部」→ 让 host 重扫一遍(`RefreshAll`)。
|
||||
//!
|
||||
//! ## 分类为什么既写视图模型、又派指令
|
||||
//!
|
||||
//! 真 host 的 `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(复审 P2-1),所以**过滤在
|
||||
//! 本页做**:写进视图模型 → 立刻 `refresh_view()`(它会把 category 带回去、按同一谓词过滤行)→
|
||||
//! 再派指令。这不是「乐观回显」:写的是**过滤条件本身**,chip 与列表用同一个
|
||||
//! [`Category::matches`](crate::pages::Category),于是不可能出现「chip 说正式版、列表是全量」的两份真相。
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 下载页的消息。**处理了返回 `true`**(`on_page` 里就不用再写一遍这些 arm)。
|
||||
pub(super) fn on_download(&mut self, message: &pages::Message) -> bool {
|
||||
match message {
|
||||
pages::Message::SelectVersion(id) => {
|
||||
self.view.download.selected = Some(id.clone());
|
||||
}
|
||||
pages::Message::InstallVersion(id) => {
|
||||
self.dispatch(AppCommand::InstallVersion(id.clone()));
|
||||
}
|
||||
pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall),
|
||||
pages::Message::SetCategory(category) => {
|
||||
self.view.download.category = *category;
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
if let Some(kind) = category.command() {
|
||||
self.dispatch(AppCommand::ListVersions(kind));
|
||||
}
|
||||
}
|
||||
pages::Message::Action(pages::Action::RefreshSources) => {
|
||||
match self.view.download.category.command() {
|
||||
Some(kind) => self.dispatch(AppCommand::ListVersions(kind)),
|
||||
None => self.dispatch(AppCommand::RefreshAll),
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 下载页(M94)在 app 层的接线测试:**命令出得去、状态不自己变**。
|
||||
//!
|
||||
//! 从 `app/update.rs` 外置(那边 348 行 + 这个模块会顶破 400 行硬上限;同一句话的理由见
|
||||
//! `crates/i18n/src/catalog/tests.rs`)。`use super::*` 依然指向 `crate::app::update`。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_presenter::dto::{AppEvent, Backend, VersionCategory};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 只记账、**不回事件**的后端(同 `app/tests/presenter.rs`):命令出了 UI 层,状态一个字节都不许变。
|
||||
/// 不用 `MockBackend`:它同步回事件,「本地乐观回显」与「事件回流」终态相同,断言会**假绿**。
|
||||
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
|
||||
|
||||
impl Backend for Recorder {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
self.0.borrow_mut().push(command);
|
||||
}
|
||||
}
|
||||
|
||||
/// 四条下载页消息:三条派真指令,`SelectVersion` 只改视图模型(P3-2 的不乐观回显)。
|
||||
/// 抓得住:意图被写成「本地改状态就算完」、或派错指令;抓不住:host 收到后真的开始下载。
|
||||
#[test]
|
||||
fn download_intents_dispatch_real_commands() {
|
||||
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let families = crate::theme::Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
let mut app = App::with_presenter(
|
||||
crate::cli::Options::default(),
|
||||
families,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(Recorder(Rc::clone(&log))),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
app.on_page(pages::Message::InstallVersion("1.21.9".to_string()));
|
||||
app.on_page(pages::Message::CancelInstall);
|
||||
app.on_page(pages::Message::SetCategory(pages::Category::Release));
|
||||
app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
|
||||
let expected = vec![
|
||||
AppCommand::InstallVersion("1.21.9".to_string()),
|
||||
AppCommand::CancelInstall,
|
||||
AppCommand::ListVersions(VersionCategory::Release),
|
||||
];
|
||||
assert_eq!(
|
||||
*log.borrow(),
|
||||
expected,
|
||||
"三条各派一条;`SelectVersion` 不派"
|
||||
);
|
||||
assert_eq!(app.view.download.selected.as_deref(), Some("1.21.8"));
|
||||
assert_eq!(app.view.download.category, pages::Category::Release);
|
||||
assert_eq!(
|
||||
app.view.download.rows.len(),
|
||||
0,
|
||||
"recorder 不回事件 ⇒ 列表不该冒出来"
|
||||
);
|
||||
// 分类是本地过滤条件:**任何一次视图重建都不许把它打回 `All`**(复审 P2-1 的回归点)。
|
||||
// 抓得住:`refresh_view` 忘记把分类带回去(那一行删掉这条就红)。
|
||||
// 抓不住:真 host 是否按分类拉清单(那是 presenter 的 `spawn_list`,已报 finding)。
|
||||
app.refresh_view();
|
||||
assert_eq!(
|
||||
app.view.download.category,
|
||||
pages::Category::Release,
|
||||
"重建视图模型之后分类必须还在"
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
forced: Option<crate::cli::ForcedState>,
|
||||
category: pages::Category,
|
||||
) -> pages::Model {
|
||||
let route = state.route().clone();
|
||||
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
|
||||
@@ -113,6 +114,7 @@ pub(super) fn model(
|
||||
window_width: width,
|
||||
active: route.nav(),
|
||||
instances: pages::Instances { rows, detail },
|
||||
download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()),
|
||||
forced: forced.map(|state| match state {
|
||||
crate::cli::ForcedState::Hover => Interaction::Hovered,
|
||||
crate::cli::ForcedState::Pressed => Interaction::Pressed,
|
||||
@@ -247,7 +249,7 @@ mod tests {
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let model = model(&state, 1180.0, None);
|
||||
let model = model(&state, 1180.0, None, pages::Category::All);
|
||||
assert_eq!(model.screen, pages::Screen::Instances);
|
||||
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
|
||||
assert_eq!(model.window_width, 1180.0);
|
||||
@@ -278,7 +280,7 @@ mod tests {
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let model = model(&state, 1180.0, None);
|
||||
let model = model(&state, 1180.0, None, pages::Category::All);
|
||||
assert_eq!(
|
||||
model.instances.rows[0].status.as_deref(),
|
||||
Some("Downloading · 50%")
|
||||
@@ -316,7 +318,7 @@ mod tests {
|
||||
fn the_agent_route_has_no_page() {
|
||||
let mut state = AppState::new();
|
||||
state.navigate(Route::Agent);
|
||||
let model = model(&state, 1180.0, None);
|
||||
let model = model(&state, 1180.0, None, pages::Category::All);
|
||||
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
|
||||
assert_eq!(pages::Screen::of(state.route()), None);
|
||||
assert!(model.instances.detail.is_none());
|
||||
|
||||
@@ -4,103 +4,146 @@
|
||||
//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限),
|
||||
//! 多出来的宽度留白——这就是 M68「全宽单卡」的反面。
|
||||
//!
|
||||
//! 页头 1 个操作 + 空态 3 个快捷入口(12b §15.5:≤3)。
|
||||
//! ## M94:占位控件 → 真组件 + 真数据 + 真指令(逐项对照)
|
||||
//!
|
||||
//! | 位置 | M84(占位) | M94(真组件) |
|
||||
//! |---|---|---|
|
||||
//! | 页头动作 | `frame::text_action`(`text` + 手搓 hover) | `buttons::text`(五式 + 状态层) |
|
||||
//! | 分类 | 无 | `navigation::tabs`(Primary,48 高 / 3px `Primary` 指示条) |
|
||||
//! | 列表行 | 无 | `navigation::list_item`(两行档 **60**:`row_height` 44 + 每多一行 16) |
|
||||
//! | 分隔线 | `frame::divider` | `components::divider::full` / `list` |
|
||||
//! | 安装按钮 | 无 | `buttons::filled`(**本页唯一实心 accent**,rubric K2) |
|
||||
//! | 进度 | 无 | `feedback::progress::linear`(已知 = determinate,未知 = indeterminate) |
|
||||
//! | 空态 | `frame::empty_state` | `feedback::empty_state`(`EmptyState` 真组件) |
|
||||
//! | 数据 | `"3 sources healthy · …"` 硬编码假状态行 | [`Download`](`app/view.rs` 从 presenter 快照转) |
|
||||
//! | 文案 | 英文字面量 | `unlucky_i18n` 的 `Key`(拼错编译失败;中英两套目录) |
|
||||
//!
|
||||
//! ## 状态矩阵(四个状态都能从快照推出来,都出图)
|
||||
//!
|
||||
//! | 状态 | 条件 | 组件 |
|
||||
//! |---|---|---|
|
||||
//! | 加载 | 快照里没有版本列表([`Download::is_loading`]) | 标题 + 说明,**居中、无进度形状**([`states::loading`]) |
|
||||
//! | 空 | 列表拉到了、过滤后为空([`Download::is_empty`]) | `EmptyState` + 刷新 CTA,**同样居中**([`states::empty`]) |
|
||||
//! | 有数据 | 有版本行 | Tabs + 计数头 + `ListItem` 列表([`rows`]) |
|
||||
//! | 安装中 | `AppState::install` 在跑 | 阶段 + **进度条(仅当进度已知)** + 计数 + 「取消」([`install`]) |
|
||||
//! | 失败 | `install.stage == Failed` | **`ErrorContainer` 面板**:`Error` 图标/标题 + 实心「打开日志」,**没有进度条**([`install`]) |
|
||||
//!
|
||||
//! 两个占位状态(加载 / 空)走**同一套规则**:内容在可用区居中(依据:`feedback::empty_state`
|
||||
//! 的既有口径 `12b §15.4`,且两态互切时内容不会纵向跳)。
|
||||
//!
|
||||
//! ## 意图 → 消息 → 指令(页面只发意图,不做乐观回显)
|
||||
//!
|
||||
//! | 用户动作 | 页面消息 | app 层落点 |
|
||||
//! |---|---|---|
|
||||
//! | 点分类 tab | [`Message::SetCategory`] | `AppCommand::ListVersions(category)`(`All` 不派:画现有快照) |
|
||||
//! | 点一行 | [`Message::SelectVersion`] | **只改视图模型**(选中不是业务命令) |
|
||||
//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
|
||||
//! | 点「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
|
||||
//! | 点「刷新」/「打开日志」 | `Message::Action(RefreshSources)` / `Message::Navigate(Logs)` | `ListVersions` / `AppState::navigate` |
|
||||
//!
|
||||
//! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。
|
||||
//! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。
|
||||
//!
|
||||
//! ## 版面判据(一条没动)
|
||||
//!
|
||||
//! 正文列 424 硬上限、断点 600 / 840、rail 88 都由 [`super::layout`] 与 `shell` 无条件施加,
|
||||
//! 本页拿不到「更宽」的选项;页头仍按 M3 口径铺满页宽(偏离 #1,未变)。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
mod demo;
|
||||
mod install;
|
||||
mod model;
|
||||
mod rows;
|
||||
mod states;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use demo::demo;
|
||||
pub use model::{Category, Download};
|
||||
|
||||
use iced::widget::column;
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::divider;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::frame::{self, Header};
|
||||
use super::layout::FrameMetrics;
|
||||
use super::{Action, Message};
|
||||
|
||||
/// 整页。
|
||||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
|
||||
let header = Header::new("Download")
|
||||
.subtitle("Versions, mod loaders and modpacks")
|
||||
.action(frame::text_action(
|
||||
/// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。
|
||||
///
|
||||
/// `lang` 是 i18n 的入参:`theme::Ui` 还没有 language 字段(那条在 M93 手里),产品路径现传
|
||||
/// [`Lang::En`](D28 的英文基线零中文负担);接上探测结果时只改调用点,页面不用动。
|
||||
pub fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
model: &'a Download,
|
||||
lang: Lang,
|
||||
) -> Element<'a, Message> {
|
||||
let catalog = lang.catalog();
|
||||
|
||||
let header = Header::new(catalog.text(Key::DownloadTitle))
|
||||
.subtitle(catalog.text(Key::DownloadSubtitle))
|
||||
.action(buttons::text(
|
||||
ui,
|
||||
"Refresh sources",
|
||||
Action::RefreshSources,
|
||||
Button::new(
|
||||
catalog.text(Key::DownloadRefresh),
|
||||
Message::Action(Action::RefreshSources),
|
||||
),
|
||||
));
|
||||
|
||||
let empty = frame::empty_state(
|
||||
ui,
|
||||
IconKind::Download,
|
||||
"No downloads in progress",
|
||||
"Pick a version or drop a modpack file here. Sources are probed per region and can be pinned \
|
||||
in Settings → Downloads.",
|
||||
vec![
|
||||
frame::text_action(ui, "Browse versions", Action::BrowseVersions),
|
||||
frame::text_action(ui, "Import modpack", Action::ImportModpack),
|
||||
frame::text_action(ui, "Check sources", Action::CheckSources),
|
||||
],
|
||||
);
|
||||
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
|
||||
}
|
||||
|
||||
let body = frame::single_column(
|
||||
metrics,
|
||||
column![source_status(ui), empty]
|
||||
.spacing(ui.space(Space::X4))
|
||||
/// 正文:加载 / 空 / 列表 + 底部安装面板(列宽由 [`super::layout`] 决议,页面只吃结果)。
|
||||
fn body<'a>(
|
||||
ui: &'a Ui,
|
||||
metrics: FrameMetrics,
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
if model.is_loading() || model.is_empty() {
|
||||
let placeholder = if model.is_loading() {
|
||||
states::loading(ui, catalog)
|
||||
} else {
|
||||
states::empty(ui, catalog)
|
||||
};
|
||||
// 占位状态下**也**把安装面板带上(复审 p3):列表空/还没到,不代表安装停了——
|
||||
// 没有这一段的话,安装进行中一旦列表被过滤空,进度条会凭空消失。
|
||||
let content: Element<'a, Message> = if model.install.is_some() {
|
||||
column![
|
||||
placeholder,
|
||||
divider::full(ui),
|
||||
install::panel(ui, catalog, model)
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
);
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
placeholder
|
||||
};
|
||||
return frame::single_column(metrics, content);
|
||||
}
|
||||
|
||||
frame::page(ui, metrics, header, body)
|
||||
}
|
||||
|
||||
/// 来源状态行(12pt,`OnSurfaceVariant`):说明这一页的数据从哪来,不是业务数据。
|
||||
fn source_status<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
container(
|
||||
row![
|
||||
frame::glyph(
|
||||
IconKind::Check,
|
||||
ui.color(Role::Secondary),
|
||||
frame::ICON_SMALL_PX
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
"3 sources healthy · BMCLAPI pinned by region · 2 min ago",
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
let content: Element<'a, Message> = column![
|
||||
rows::categories(ui, catalog, model.category),
|
||||
rows::head(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
rows::list(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
install::panel(ui, catalog, model),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
|
||||
|
||||
/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。
|
||||
#[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 的全宽卡就是没有这段留白)"
|
||||
);
|
||||
}
|
||||
frame::single_column(metrics, content)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 下载页的**演示数据**(截图 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`。
|
||||
//!
|
||||
//! `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};
|
||||
|
||||
/// 取证开关的名字([`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)
|
||||
},
|
||||
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),
|
||||
Some("installing-empty") => Download {
|
||||
install: Some(install(InstallStage::Downloading, 0.42, catalog)),
|
||||
..Download::loading()
|
||||
},
|
||||
_ => ready(catalog),
|
||||
}
|
||||
}
|
||||
|
||||
/// 有数据:六行(三条正式、一条预览、两条远古),与 presenter 的 `mock_versions` 同形。
|
||||
///
|
||||
/// 不复用 `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),
|
||||
rows,
|
||||
selected: None,
|
||||
install: None,
|
||||
loaded: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。
|
||||
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.21.9-pre3", VersionKind::Snapshot, "2026-09-01", 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, 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, catalog)
|
||||
}
|
||||
|
||||
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! 安装面板(页面底部):选中版本 → 「安装」/ 进度 → 「取消」/ 失败 → 「打开日志」。
|
||||
//!
|
||||
//! ## 这一块为什么钉在底部而不是跟着列表滚
|
||||
//!
|
||||
//! 列表装的是几十行版本;底部按钮如果跟着滚,用户在列表中间永远看不到「安装」在哪。
|
||||
//! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。
|
||||
//!
|
||||
//! ## 只有意图,没有乐观回显
|
||||
//!
|
||||
//! | 控件 | 消息 | app 层落点 |
|
||||
//! |---|---|---|
|
||||
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
|
||||
//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
|
||||
//! | 「打开日志」 | [`Message::Navigate`](`Route::Logs`) | `AppState::navigate`(不派指令) |
|
||||
//!
|
||||
//! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的
|
||||
//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件。
|
||||
//!
|
||||
//! ## 三种面板形态刻意不同构(M94 r1 / P2-3 + P2-4)
|
||||
//!
|
||||
//! | 状态 | 形态 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 选好了 | 版本 id + 实心「安装」 | 本页唯一的实心按钮(rubric K2) |
|
||||
//! | 安装中 | 版本 id + 阶段 + **进度条**(仅当进度已知)+ 计数 + 文本「取消安装」 | 有进度才有条 |
|
||||
//! | 失败 | **整块 `ErrorContainer` 面板**:`Error` 图标 + `Error` 色标题 + 停在哪 + 实心「打开日志」 | **不画进度条**——进度条正是「看起来还在跑」的来源;颜色/图标/措辞/按钮四样一起变,才一眼可辨 |
|
||||
//!
|
||||
//! 进度未知(刚派发、后端还没给文件计数)时**不画条**:页面没有帧订阅,`Progress::indeterminate`
|
||||
//! 只会得到一个静止的假进度(复审 P2-3 的「17% 卡住」),不如只写「等第一个文件…」。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::feedback::{linear, Progress};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::Message;
|
||||
use super::model::{Download, InstallView};
|
||||
|
||||
/// 面板要画哪一种形态(**决策与画法分开**:决策是纯函数,单测直接钉它)。
|
||||
///
|
||||
/// 分开的理由就是本轮复审那件事:「装完之后面板还占着、用户再也装不了第二个版本」——
|
||||
/// 这是**决策**错了,画法本身没错;把决策抽出来,测试才能在不开图形环境的情况下断言它。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(super) enum PanelState<'a> {
|
||||
/// 没有安装、也没选中:一句「选一个版本再安装」
|
||||
Hint,
|
||||
/// 选中了一个版本:给出「安装」(已装的给禁用态)
|
||||
Ready {
|
||||
/// 选中的版本 id(= `AppCommand::InstallVersion` 的载荷)
|
||||
id: &'a str,
|
||||
/// 本地已经装过(按钮禁用,列表行上也有「已安装」+ 勾)
|
||||
installed: bool,
|
||||
},
|
||||
/// 有安装(进行中 / 失败):进度或错误面板
|
||||
Install(&'a InstallView),
|
||||
}
|
||||
|
||||
/// 面板此刻该画哪一种(纯函数,无 iced)。
|
||||
///
|
||||
/// 顺序是**安装优先**:有安装时面板讲安装,讲不了列表;安装结束后 `model.install` 由
|
||||
/// [`Download::from_snapshot`](super::model::Download::from_snapshot) 清掉,于是这里自然回到
|
||||
/// [`PanelState::Ready`] / [`PanelState::Hint`],用户能接着选下一个版本。
|
||||
pub(super) fn panel_state(model: &Download) -> PanelState<'_> {
|
||||
match &model.install {
|
||||
Some(install) => PanelState::Install(install),
|
||||
None => match model.selected_row() {
|
||||
Some(row) => PanelState::Ready {
|
||||
id: row.id.as_str(),
|
||||
installed: row.installed,
|
||||
},
|
||||
None => PanelState::Hint,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部的安装面板。
|
||||
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
|
||||
let content = match panel_state(model) {
|
||||
PanelState::Install(install) => installing(ui, catalog, install),
|
||||
PanelState::Ready { id, installed } => ready(ui, catalog, id, installed),
|
||||
PanelState::Hint => hint(ui, catalog),
|
||||
};
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X3), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 没选中任何版本:给一句「选一个版本再安装」(不静默禁用,也不画一个点不动的按钮)。
|
||||
fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::DownloadSelectHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 选好了:版本 id + 「安装」(全屏唯一实心 accent,rubric K2)。
|
||||
///
|
||||
/// 已装的版本给**禁用**的 Outlined 按钮(`Button::disabled` 只清消息、保留几何):
|
||||
/// 「已装」是列表行上的事实(overline + 勾),按钮再点一次不会更清楚。
|
||||
fn ready<'a>(ui: &'a Ui, catalog: Catalog, id: &'a str, installed: bool) -> Element<'a, Message> {
|
||||
let label = container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
id,
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill);
|
||||
|
||||
let action = if installed {
|
||||
buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall)))
|
||||
} else {
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::DownloadInstall),
|
||||
Message::InstallVersion(id.to_string()),
|
||||
)
|
||||
.leading(IconKind::Download),
|
||||
)
|
||||
};
|
||||
|
||||
row![label, action]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 安装中 / 已收尾(失败另走 [`failed`])。
|
||||
fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
|
||||
if install.failed() {
|
||||
return failed(ui, catalog, install);
|
||||
}
|
||||
|
||||
let head = row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
install.version.as_str(),
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
install.stage_label.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let mut body = column![head];
|
||||
// 进度已知才画条:未知时画 indeterminate 会得到一个静止的假进度(见模块文档)。
|
||||
if install.known {
|
||||
body = body.push(linear(ui, Progress::determinate(install.ratio)));
|
||||
}
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
install.detail.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
if let Some(rate) = &install.rate {
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
rate.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
|
||||
let action = if install.running() {
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::DownloadCancel), Message::CancelInstall),
|
||||
)
|
||||
} else {
|
||||
frame::gap(0.0)
|
||||
};
|
||||
|
||||
column![
|
||||
body.spacing(ui.space(Space::X2)).width(Length::Fill),
|
||||
action
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 失败态:一整块 `ErrorContainer` 面板(**没有进度条**)。
|
||||
///
|
||||
/// 四样一起变,保证与「正在下载」一眼可辨:容器色(`ErrorContainer`)/ 图标(`Error`)/
|
||||
/// 措辞(`Install failed` 用 `Error` 色标题)/ 主按钮(实心「打开日志」,不是文本按钮)。
|
||||
fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
|
||||
let head = row![
|
||||
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::TitleSmall,
|
||||
true,
|
||||
catalog.text(Key::DownloadStageFailed),
|
||||
ui.color(Role::Error),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let stopped = frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
format!("{} · {}", install.version, install.detail),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
);
|
||||
|
||||
let actions = row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::DownloadOpenLogs),
|
||||
Message::Navigate(Route::Logs),
|
||||
)
|
||||
.leading(IconKind::Logs),
|
||||
),
|
||||
// 失败把面板钉住了:不给出口的话用户既重试不了、也装不了别的版本(只能重启)。
|
||||
// `CancelInstall` 在 host 侧就是「清 install + 发 InstallProgress(None)」,对没在跑的任务是安全 no-op。
|
||||
buttons::text(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::DownloadDismiss), Message::CancelInstall),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
container(
|
||||
column![
|
||||
head,
|
||||
stopped,
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::DownloadFailedHint),
|
||||
ui.color(Role::OnErrorContainer),
|
||||
),
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.space(Space::X4))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
|
||||
border: Border {
|
||||
radius: ui.radius_dp(ShapeScale::Large),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,118 @@
|
||||
//! 安装面板的决策测试(M94 r2)。
|
||||
//!
|
||||
//! 从 `install.rs` 外置(那边加上本轮修复会顶到 400 行)。`use super::*` 仍指向
|
||||
//! `crate::pages::download::install`,`PanelState` / `panel_state` 是那里的 `pub(super)`。
|
||||
//!
|
||||
//! **不走 `MockBackend`**:`MockBackend::script_install` 只发 Dialog + Toast、从不发
|
||||
//! `InstallProgress`(复审 r1 的取证),所以 mock 下 `install` 恒为 `None`,终态分支永远跑不到——
|
||||
//! 用 mock 写的测试会**假绿**。这里直接构造 `AppState` 并 `apply(CoreEvent::InstallProgress(..))`。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppEvent, CoreEvent, InstallProgressView, InstallStage, VersionKind, VersionSummary,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::super::model::{Category, Download};
|
||||
use super::{panel_state, PanelState};
|
||||
|
||||
fn version(id: &str) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind: VersionKind::Release,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn progress(stage: InstallStage) -> InstallProgressView {
|
||||
InstallProgressView {
|
||||
version: "1.21.9".to_string(),
|
||||
stage,
|
||||
files_done: 512,
|
||||
files_total: 512,
|
||||
..InstallProgressView::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(stage: InstallStage) -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(vec![
|
||||
version("1.21.9"),
|
||||
version("1.21.8"),
|
||||
])));
|
||||
state.apply(AppEvent::Core(CoreEvent::InstallProgress(Some(progress(
|
||||
stage,
|
||||
)))));
|
||||
state
|
||||
}
|
||||
|
||||
/// **装完之后页面必须回到「能再装下一个」**(复审 p2 的修复验收)。
|
||||
///
|
||||
/// 复审现象:`InstallDone` 从不清 `AppState.install`(presenter 只在取消时清),而面板**优先**
|
||||
/// 用 `install`,终态又没有可执行动作 ⇒ 装完第一个版本后按钮永久消失,只能重启进程。
|
||||
///
|
||||
/// 抓得住:`Done` 之后面板还被 `install` 占着(= 装不了第二个版本的根因)、列表没回来、
|
||||
/// 面板不再给「安装」。测试直接钉 [`panel_state`]——那是 `panel()` 唯一的决策入口,两者不会漂。
|
||||
/// 抓不住:presenter 侧 `AppState.install` 仍是 `Some(Done)`(proper fix 不在本 mission,已报 finding);
|
||||
/// 也抓不住「装完后已装标记不刷新」(同一个 finding)。
|
||||
#[test]
|
||||
fn a_finished_install_gives_the_page_back_to_the_list() {
|
||||
let running = Download::from_snapshot(
|
||||
&snapshot(InstallStage::Downloading),
|
||||
Category::All,
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(running.install.is_some(), "跑着的时候面板要在");
|
||||
assert!(matches!(panel_state(&running), PanelState::Install(_)));
|
||||
|
||||
let state = snapshot(InstallStage::Done);
|
||||
assert_eq!(
|
||||
state.install.as_ref().map(|view| view.stage),
|
||||
Some(InstallStage::Done),
|
||||
"现实如此:快照里仍留着终态(presenter 不清),页面必须自己让位"
|
||||
);
|
||||
|
||||
let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
assert!(model.install.is_none(), "Done 之后不许再占着安装面板");
|
||||
assert!(model.selected.is_none(), "装完不替用户选中任何行");
|
||||
assert_eq!(model.rows.len(), 2, "列表要回来");
|
||||
assert_eq!(
|
||||
panel_state(&model),
|
||||
PanelState::Hint,
|
||||
"回到「选一个版本再安装」"
|
||||
);
|
||||
|
||||
// 再装一次:选中下一个版本 → 面板重新给出「安装」(= `Message::InstallVersion(id)`)
|
||||
let mut next = model.clone();
|
||||
next.selected = Some("1.21.9".to_string());
|
||||
assert_eq!(
|
||||
panel_state(&next),
|
||||
PanelState::Ready {
|
||||
id: "1.21.9",
|
||||
installed: false
|
||||
},
|
||||
"装完之后必须能接着装下一个版本"
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消与失败**不许**一起被清:失败态是 P2-4 的错误出口,取消是「已经没事了」。
|
||||
///
|
||||
/// 抓得住:把 `finished()` 写成「terminated()」(那会把失败态的日志入口一起清掉,用户失去原因)。
|
||||
/// 抓不住:presenter 在取消后是否补发 `InstallProgress(None)`(那是 presenter 的行为)。
|
||||
#[test]
|
||||
fn only_done_and_cancelled_release_the_panel() {
|
||||
let failed = Download::from_snapshot(
|
||||
&snapshot(InstallStage::Failed),
|
||||
Category::All,
|
||||
Lang::En.catalog(),
|
||||
);
|
||||
assert!(failed.install.is_some(), "失败态要留在屏幕上");
|
||||
assert!(matches!(panel_state(&failed), PanelState::Install(_)));
|
||||
|
||||
for stage in [InstallStage::Done, InstallStage::Cancelled] {
|
||||
let model = Download::from_snapshot(&snapshot(stage), Category::All, Lang::En.catalog());
|
||||
assert!(model.install.is_none(), "{stage:?} 之后面板要让位");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 下载页视图模型:**presenter 快照 → 页面要画的东西**(与 [`super::super::model`] 同一套分层)。
|
||||
//!
|
||||
//! ## 为什么这一页也要中间层
|
||||
//!
|
||||
//! 页面层不认识 presenter 的 DTO(同实例页的理由):真页面吃 `AppState` 的快照,
|
||||
//! 演示数据与单测吃 [`super::demo`],两边都转成这里的纯展示字段。
|
||||
//!
|
||||
//! 文案在这一层就按 [`Catalog`] 求好值(`supporting` / `detail` / `rate` 都是 `String`):
|
||||
//! iced 的文字控件**借用**内容,行内文案又是 `format!` 出来的,不先落成字段就没法进控件树
|
||||
//! (`pages/frame` 的 `text_role_owned` 是同一个理由的单行版)。
|
||||
//!
|
||||
//! ## 快照里**没有**的东西(都登记在 `pages/mod.rs` 的偏离表里)
|
||||
//!
|
||||
//! | 缺什么 | 后果 | 兜法 |
|
||||
//! |---|---|---|
|
||||
//! | 「版本列表已加载完成」标志 | 空列表既可能是「还没拉到」也可能是「真的没有」 | [`Download::loaded`] 按「快照里有没有版本」判:没有 = 加载中 |
|
||||
//! | 版本列表**失败**事件 | 拉取失败会停在加载态(不静默:页面上有说明与刷新入口) | 错误态借安装阶段的 `Failed`(结构化、可翻译) |
|
||||
//! | 「当前选中」字段 | 选中态存在模型里,任何快照重建都会回到「正在安装的那个版本」 | 见 `pages/mod.rs` 偏离登记;长期要靠 presenter 加字段 |
|
||||
//!
|
||||
//! ## 中文串一个都不许画
|
||||
//!
|
||||
//! `InstallProgressView::message` / `InstallStage::label()` 都是**中文业务串**(`i18n::bridge`
|
||||
//! 的规矩 2:core 的中文串只给日志和 CLI)。所以这里只搬**结构化字段**(阶段枚举、计数、字节),
|
||||
//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
human_bytes, AncientKind, InstallProgressView, InstallStage, VersionCategory, VersionKind,
|
||||
VersionSummary,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
/// 分类筛选(页面上的四个 filter chip)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Category {
|
||||
/// 不过滤:画快照里有什么就是什么(首帧与 `RefreshAll` 的落点)
|
||||
#[default]
|
||||
All,
|
||||
/// 正式版
|
||||
Release,
|
||||
/// 预览版
|
||||
Snapshot,
|
||||
/// 远古版(快照里按 `AncientKind` 分四类,页面先只认一个 chip)
|
||||
Ancient,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
/// 四个 chip 的声明顺序(= 画出来的顺序)。
|
||||
pub const ALL: [Category; 4] = [
|
||||
Category::All,
|
||||
Category::Release,
|
||||
Category::Snapshot,
|
||||
Category::Ancient,
|
||||
];
|
||||
|
||||
/// chip 文案的 key。
|
||||
pub const fn label_key(self) -> Key {
|
||||
match self {
|
||||
Category::All => Key::DownloadCategoryAll,
|
||||
Category::Release => Key::DownloadCategoryRelease,
|
||||
Category::Snapshot => Key::DownloadCategorySnapshot,
|
||||
Category::Ancient => Key::DownloadCategoryAncient,
|
||||
}
|
||||
}
|
||||
|
||||
/// 点这个 chip 要派的指令(`None` = 不派:`All` 就是「把当前快照全画出来」)。
|
||||
///
|
||||
/// 远古版在 `VersionCategory` 里是**带子类的**(`Ancient(AncientKind)`),一个 chip 拉不全四类;
|
||||
/// 这里先拉 `Classic`,四子类同屏要靠 presenter 侧支持(已登记 finding)。
|
||||
pub const fn command(self) -> Option<VersionCategory> {
|
||||
match self {
|
||||
Category::All => None,
|
||||
Category::Release => Some(VersionCategory::Release),
|
||||
Category::Snapshot => Some(VersionCategory::Snapshot),
|
||||
Category::Ancient => Some(VersionCategory::Ancient(AncientKind::Classic)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 这个版本属于这个分类吗。
|
||||
pub fn matches(self, kind: VersionKind) -> bool {
|
||||
match self {
|
||||
Category::All => true,
|
||||
Category::Release => kind == VersionKind::Release,
|
||||
Category::Snapshot => kind == VersionKind::Snapshot,
|
||||
Category::Ancient => kind.is_ancient(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从快照里**反推**当前分类:整份列表只有一类 → 那一类;混合或为空 → [`Category::All`]。
|
||||
///
|
||||
/// 快照没有「我上次按哪个分类拉的」这种字段,所以分类靠列表内容反推
|
||||
/// (`ListVersions` 的结果是单一分类,`RefreshAll` 的结果是混合)。
|
||||
pub fn of(versions: &[VersionSummary]) -> Self {
|
||||
let mut kinds = versions.iter().map(|version| version.kind);
|
||||
let Some(first) = kinds.next() else {
|
||||
return Category::All;
|
||||
};
|
||||
if kinds.all(|kind| kind == first) {
|
||||
match first {
|
||||
VersionKind::Release => Category::Release,
|
||||
VersionKind::Snapshot => Category::Snapshot,
|
||||
VersionKind::Ancient(_) => Category::Ancient,
|
||||
}
|
||||
} else {
|
||||
Category::All
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本列表的一行(`ListItem` 的两行档:headline = 版本 id,supporting = `正式版 · 发布于 …`)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VersionRow {
|
||||
/// 版本 id(`1.21.9` / `rd-132211`)——也是「安装」指令的载荷
|
||||
pub id: String,
|
||||
/// 发布日期(ISO-8601 日期部分,快照原样)
|
||||
pub released: String,
|
||||
/// 版本类型
|
||||
pub kind: VersionKind,
|
||||
/// 本地是否已安装(行上给勾 / overline `Installed`)
|
||||
pub installed: bool,
|
||||
/// 副文案(`正式版 · 发布于 2026-09-20`;一行 = 一个 key,不拼片段)
|
||||
pub supporting: String,
|
||||
}
|
||||
|
||||
impl VersionRow {
|
||||
pub(super) fn of(version: &VersionSummary, catalog: Catalog) -> Self {
|
||||
Self {
|
||||
id: version.id.clone(),
|
||||
released: version.released.clone(),
|
||||
kind: version.kind,
|
||||
installed: version.installed,
|
||||
supporting: catalog.render(
|
||||
Key::DownloadRowSupporting,
|
||||
&[
|
||||
("kind", catalog.text(kind_key(version.kind))),
|
||||
("date", version.released.as_str()),
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本类型 → 文案 key(远古版不分子类:chip 只有一档,行上再分子类等于两套语言)。
|
||||
const fn kind_key(kind: VersionKind) -> Key {
|
||||
match kind {
|
||||
VersionKind::Release => Key::DownloadCategoryRelease,
|
||||
VersionKind::Snapshot => Key::DownloadCategorySnapshot,
|
||||
VersionKind::Ancient(_) => Key::DownloadCategoryAncient,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次安装的展示态(结构化字段 + 已求好的文案)。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct InstallView {
|
||||
/// 目标版本(= 选中的那一行)
|
||||
pub version: String,
|
||||
/// 阶段
|
||||
pub stage: InstallStage,
|
||||
/// 阶段词(`下载文件`)
|
||||
pub stage_label: String,
|
||||
/// 计数行(`128/512 文件`;未知时是「等第一个文件…」)
|
||||
pub detail: String,
|
||||
/// 速率 + 剩余时间(`1.2 MB/s · 剩 1m15s`;未知时 `None`)
|
||||
pub rate: Option<String>,
|
||||
/// 整体进度 0.0~1.0
|
||||
pub ratio: f32,
|
||||
/// 进度是否已知(`false` = 进度条画不确定态)
|
||||
pub known: bool,
|
||||
}
|
||||
|
||||
impl InstallView {
|
||||
/// 快照 → 展示态。
|
||||
pub(super) fn of(progress: &InstallProgressView, catalog: Catalog) -> Self {
|
||||
let known = progress.is_known();
|
||||
let detail = if known {
|
||||
catalog.render(
|
||||
Key::DownloadProgress,
|
||||
&[
|
||||
("done", &progress.files_done.to_string()),
|
||||
("total", &progress.files_total.to_string()),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
catalog.text(Key::DownloadProgressUnknown).to_owned()
|
||||
};
|
||||
let rate = if progress.speed_bps == 0 || progress.eta_secs == 0 {
|
||||
None
|
||||
} else {
|
||||
let eta = if progress.eta_secs >= 60 {
|
||||
catalog.render(
|
||||
Key::DownloadEtaMins,
|
||||
&[
|
||||
("mins", &(progress.eta_secs / 60).to_string()),
|
||||
("secs", &format!("{:02}", progress.eta_secs % 60)),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
catalog.render(
|
||||
Key::DownloadEtaSecs,
|
||||
&[("secs", &progress.eta_secs.to_string())],
|
||||
)
|
||||
};
|
||||
Some(catalog.render(
|
||||
Key::DownloadRate,
|
||||
&[
|
||||
("speed", &format!("{}/s", human_bytes(progress.speed_bps))),
|
||||
("eta", eta.as_str()),
|
||||
],
|
||||
))
|
||||
};
|
||||
|
||||
Self {
|
||||
version: progress.version.clone(),
|
||||
stage: progress.stage,
|
||||
stage_label: catalog.text(stage_key(progress.stage)).to_owned(),
|
||||
detail,
|
||||
rate,
|
||||
ratio: progress.ratio(),
|
||||
known,
|
||||
}
|
||||
}
|
||||
|
||||
/// 失败了(错误态要出可读文案 + 出口,不许静默)。
|
||||
pub const fn failed(&self) -> bool {
|
||||
matches!(self.stage, InstallStage::Failed)
|
||||
}
|
||||
|
||||
/// 收尾了(`Done` / `Failed` / `Cancelled`)。
|
||||
pub fn terminal(&self) -> bool {
|
||||
self.stage.is_terminal()
|
||||
}
|
||||
|
||||
/// **结束且没什么可说**的收尾:成功(`Done`)与取消(`Cancelled`)。
|
||||
///
|
||||
/// 这两种终态页面没有动作可给,留着只会让安装面板停在一个点不动的状态上(复审 p2 的根因)。
|
||||
/// 失败**不在其中**:它还要给原因出口。
|
||||
pub fn finished(&self) -> bool {
|
||||
matches!(self.stage, InstallStage::Done | InstallStage::Cancelled)
|
||||
}
|
||||
|
||||
/// 还在跑(「取消安装」只在这时给)。
|
||||
pub fn running(&self) -> bool {
|
||||
!self.stage.is_terminal()
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段 → 文案 key(**不**用 `InstallStage::label()`:那是中文业务串)。
|
||||
const fn stage_key(stage: InstallStage) -> Key {
|
||||
match stage {
|
||||
InstallStage::Planning => Key::DownloadStagePlanning,
|
||||
InstallStage::Downloading => Key::DownloadStageDownloading,
|
||||
InstallStage::Installing => Key::DownloadStageInstalling,
|
||||
InstallStage::Registering => Key::DownloadStageRegistering,
|
||||
InstallStage::Done => Key::DownloadStageDone,
|
||||
InstallStage::Failed => Key::DownloadStageFailed,
|
||||
InstallStage::Cancelled => Key::DownloadStageCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载页要画的东西(**全部字段都能从快照推出来**,页面自己不留状态)。
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Download {
|
||||
/// 版本列表(顺序 = 快照顺序)
|
||||
pub rows: Vec<VersionRow>,
|
||||
/// 当前分类(从快照反推)
|
||||
pub category: Category,
|
||||
/// 选中的版本 id(`None` = 没选中 → 安装按钮禁用)
|
||||
pub selected: Option<String>,
|
||||
/// 正在跑(或刚跑完)的安装
|
||||
pub install: Option<InstallView>,
|
||||
/// 快照里有没有版本列表(`false` = 还没拉到 → 加载态)
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
impl Download {
|
||||
/// 空模型 = 加载态(首帧,以及 `pages::Model::new` 的默认值)。
|
||||
pub fn loading() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// presenter 快照 → 下载页模型(**唯一的真数据入口**)。
|
||||
///
|
||||
/// 三个过滤条件都是「过滤」,不是「乐观回显」:
|
||||
/// - `installed_only` / `download_query` 是 presenter 自己的状态,页面不该另存一份;
|
||||
/// - `category` 是**本页的过滤条件**(参数传进来,调用方持有它):真 host 的
|
||||
/// `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(见 `host/dispatch.rs`),
|
||||
/// 所以分类**必须在页面这一侧过滤**,否则 chip 只是点亮一下、列表照旧是全量(复审 P2-1)。
|
||||
/// 过滤谓词与 chip 的选中判断是同一个 [`Category::matches`],于是「chip 说的」与「列表画的」
|
||||
/// 永远是同一份真相,事件回来重建也不会跳回「全部」。
|
||||
///
|
||||
/// 过滤后为空就是「空态」(`loaded` 仍为 `true`)。
|
||||
pub fn from_snapshot(state: &AppState, category: Category, catalog: Catalog) -> Self {
|
||||
let query = state.download_query.trim().to_ascii_lowercase();
|
||||
let rows = state
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| category.matches(version.kind))
|
||||
.filter(|version| !state.installed_only || version.installed)
|
||||
.filter(|version| query.is_empty() || version.id.to_ascii_lowercase().contains(&query))
|
||||
.map(|version| VersionRow::of(version, catalog))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let install = state
|
||||
.install
|
||||
.as_ref()
|
||||
.map(|view| InstallView::of(view, catalog))
|
||||
// 安装**已经成功结束/被取消**就不再占用面板(复审 p2,本轮的修复点):
|
||||
// presenter 只在 `InstallProgress(None)`(取消指令)时清 `AppState.install`,`Done`
|
||||
// 会一直留在快照里 ⇒ 面板被一个**没有动作**的终态钉死,用户装完第一个版本就再也发不出
|
||||
// 第二次安装(只能重启进程)。界面这里把「结束」当结束:面板让回列表。
|
||||
//
|
||||
// 为什么不连 `Failed` 一起清:失败态带着用户要的东西(停在哪个文件 + 去日志页),
|
||||
// 那正是 P2-4 要它一眼可辨的理由。
|
||||
.filter(|view| !view.finished());
|
||||
// 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。
|
||||
let selected = install.as_ref().map(|view| view.version.clone());
|
||||
|
||||
Self {
|
||||
rows,
|
||||
// 分类不再从列表反推(那正是「chip 跳回全部」的来源):谁选的就是谁。
|
||||
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
|
||||
category,
|
||||
selected,
|
||||
install,
|
||||
loaded: !state.versions.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中的那一行(安装面板上的版本名)。
|
||||
pub fn selected_row(&self) -> Option<&VersionRow> {
|
||||
let id = self.selected.as_deref()?;
|
||||
self.rows.iter().find(|row| row.id == id)
|
||||
}
|
||||
|
||||
/// 加载态:快照里还没有版本列表。
|
||||
pub const fn is_loading(&self) -> bool {
|
||||
!self.loaded
|
||||
}
|
||||
|
||||
/// 空态:列表拉到了,但过滤之后一行都不剩。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.loaded && self.rows.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 视图模型的判据(从 `model.rs` 外置:那边加上本轮的分类过滤会顶到 400 行)。
|
||||
|
||||
use unlucky_presenter::dto::{AncientKind, AppEvent, CoreEvent, VersionKind, VersionSummary};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::{Category, Download};
|
||||
|
||||
fn version(id: &str, kind: VersionKind) -> VersionSummary {
|
||||
VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind,
|
||||
released: "2026-09-20".to_string(),
|
||||
installed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with(versions: Vec<VersionSummary>) -> AppState {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(versions)));
|
||||
state
|
||||
}
|
||||
|
||||
/// 分类筛选是**本页的过滤**(复审 P2-1:真 host 的 `ListVersions(_)` 丢掉分类参数、回全量清单)。
|
||||
///
|
||||
/// 抓得住:`from_snapshot` 忘了按分类过滤——那正是「chip 点亮一下、列表照旧全量、事件回来 chip
|
||||
/// 跳回『全部』」的根因;也抓得住「分类被列表反推覆盖」(`Category::of` 回到 `All`)。
|
||||
/// 抓不住:真 host 是否按分类拉清单(`host/dispatch.rs:30` 丢参数,已报 finding)。
|
||||
#[test]
|
||||
fn the_category_filters_the_list_locally() {
|
||||
let state = state_with(vec![
|
||||
version("1.21.9", VersionKind::Release),
|
||||
version("1.21.9-pre3", VersionKind::Snapshot),
|
||||
version("rd-132211", VersionKind::Ancient(AncientKind::Classic)),
|
||||
]);
|
||||
|
||||
let release = Download::from_snapshot(&state, Category::Release, Lang::En.catalog());
|
||||
assert_eq!(
|
||||
release.category,
|
||||
Category::Release,
|
||||
"谁选的就是谁:不从列表反推(反推会把混合列表打回 All)"
|
||||
);
|
||||
assert_eq!(release.rows.len(), 1, "只剩正式版");
|
||||
assert_eq!(release.rows[0].id, "1.21.9");
|
||||
|
||||
let all = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
|
||||
assert_eq!(all.rows.len(), 3, "「全部」不过滤");
|
||||
}
|
||||
|
||||
/// 分类把行全滤掉时是**空态**,不是加载态(两态文案与位置不同,别混)。
|
||||
///
|
||||
/// 抓得住:把「筛出来是空」当成「还没拉到」。
|
||||
/// 抓不住:用户看到空态之后会怎么操作(那是 UI 层)。
|
||||
#[test]
|
||||
fn a_category_that_hides_everything_is_the_empty_state() {
|
||||
let state = state_with(vec![version("1.21.9", VersionKind::Release)]);
|
||||
let ancient = Download::from_snapshot(&state, Category::Ancient, Lang::En.catalog());
|
||||
assert!(ancient.rows.is_empty());
|
||||
assert!(!ancient.is_loading(), "快照里有版本 ⇒ 不是加载中");
|
||||
assert!(ancient.is_empty(), "筛掉所有行 ⇒ 空态");
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 版本列表:分类 Tabs + 计数头 + `ListItem` 行(全是 `components/` 的真组件)。
|
||||
//!
|
||||
//! | 位置 | M84(占位) | M94(真组件) |
|
||||
//! |---|---|---|
|
||||
//! | 分类 | 无(整页只有一行假状态) | [`navigation::tabs`] 的 Primary 档(48 高、3px `Primary` 指示条) |
|
||||
//! | 列表行 | 无 | [`navigation::list_item`] 的两行档(**60** = `row_height` 44 + 每多一行 16) |
|
||||
//! | 计数头 | 无 | `LabelMedium` + `OnSurfaceVariant`(与实例页列表头同一个语言) |
|
||||
//! | 已装标记 | 无 | overline `Installed` + trailing `Check`(`Primary`) |
|
||||
//!
|
||||
//! 行高从「手搓 56」变成 60 的理由与实例页完全一样:真 `ListItem` 自己的几何真源,页面不覆盖。
|
||||
|
||||
use iced::widget::{column, scrollable};
|
||||
use iced::{Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::navigation::list_item::{list_item, ListItem};
|
||||
use crate::app::components::navigation::tabs::{tabs, Tab, Tabs};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::Message;
|
||||
use super::model::{Category, Download, VersionRow};
|
||||
|
||||
/// 行与行的间距(`Space::X1`;与实例页同值,行高差异全部来自 `ListItem` 自己的几何)。
|
||||
const ROW_GAP: Space = Space::X1;
|
||||
|
||||
/// 分类筛选(Primary Tabs:选中项下方 3px `Primary` 指示条)。
|
||||
///
|
||||
/// 点一个分类 = `Message::SetCategory`,由 app 层翻成 `AppCommand::ListVersions`——
|
||||
/// 页面**不**自己过滤列表(列表内容 = 快照里有什么),所以「点了但没反应」只会发生在
|
||||
/// presenter 没回列表的时候,不会出现「页面过滤过、后端还是旧分类」的两份真相。
|
||||
pub(super) fn categories<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
current: Category,
|
||||
) -> Element<'a, Message> {
|
||||
let mut row = Tabs::primary();
|
||||
for category in Category::ALL {
|
||||
let mut tab = Tab::new(
|
||||
catalog.text(category.label_key()),
|
||||
Message::SetCategory(category),
|
||||
);
|
||||
if category == current {
|
||||
tab = tab.selected();
|
||||
}
|
||||
row = row.tab(tab);
|
||||
}
|
||||
tabs(ui, row)
|
||||
}
|
||||
|
||||
/// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。
|
||||
pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, model: &Download) -> Element<'a, Message> {
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::DownloadCount,
|
||||
&[("count", &model.rows.len().to_string())],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。
|
||||
pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
|
||||
let rows = column(
|
||||
model
|
||||
.rows
|
||||
.iter()
|
||||
.map(|entry| version_row(ui, catalog, model, entry))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(ui.space(ROW_GAP))
|
||||
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
||||
.width(Length::Fill);
|
||||
|
||||
scrollable(rows)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
.scroller_width(frame::SCROLLBAR_WIDTH)
|
||||
.margin(frame::SCROLLBAR_MARGIN),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(frame::scroll_style(ui))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行:headline = 版本 id,supporting = `正式版 · 发布于 2026-09-20`,已装给勾。
|
||||
///
|
||||
/// 点一行 = `Message::SelectVersion(id)`(**页面内选中**,不 dispatch);「安装」是旁边那个
|
||||
/// 独立按钮,点它才派 `AppCommand::InstallVersion`。选中不做乐观回显:行的高亮来自模型,
|
||||
/// 模型在 app 层被改写之后才变。
|
||||
fn version_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &Download,
|
||||
entry: &'a VersionRow,
|
||||
) -> Element<'a, Message> {
|
||||
let mut item = ListItem::new(entry.id.as_str(), Message::SelectVersion(entry.id.clone()))
|
||||
.supporting(entry.supporting.as_str())
|
||||
.leading(IconKind::Download);
|
||||
|
||||
if entry.installed {
|
||||
item = item
|
||||
.overline(catalog.text(Key::DownloadInstalled))
|
||||
.trailing(IconKind::Check);
|
||||
}
|
||||
if model.selected.as_deref() == Some(entry.id.as_str()) {
|
||||
item = item.selected();
|
||||
}
|
||||
|
||||
list_item(ui, item)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 占位状态(加载 / 空):**同一套占位规则——内容在可用区居中**(M94 r1 / P2-1)。
|
||||
//!
|
||||
//! | 状态 | 条件(都能从快照推出来) | 画什么 |
|
||||
//! |---|---|---|
|
||||
//! | 加载 | `model.is_loading()`:快照里还没有版本列表 | 标题 + 一句说明(**没有任何进度形状**) |
|
||||
//! | 空 | `model.is_empty()`:列表拉到了,但过滤后一行不剩 | 真 [`empty_state`](图标块 96 + 24 标题 + 说明 + 刷新 CTA) |
|
||||
//!
|
||||
//! ## 为什么是「都居中」(P2-1)
|
||||
//!
|
||||
//! `components::feedback::empty_state` 的既有口径(`12b §15.4`)就是「内容在可用区居中」;
|
||||
//! 加载态原先贴顶,等于同一个页面里两套占位规则,两态互切时内容会纵向跳一次。所以本轮把加载态
|
||||
//! 也放进同一条规则里。**有数据时列表仍然贴顶**——那是有内容的状态,不属于占位规则。
|
||||
//!
|
||||
//! ## 为什么加载态没有进度条(P2-3)
|
||||
//!
|
||||
//! 页面**没有帧订阅**(`app/subscription.rs` 的 `needs_frames` 归 app 层),所以
|
||||
//! `Progress::indeterminate(phase)` 拿到的是一个**静止的**相位——用户看到的是「17% 卡住」。
|
||||
//! 与其留一个「看起来有进度但不动」的控件,不如给一句明确的「正在加载」文字;等 app 层真有了
|
||||
//! 帧订阅再来带动画(那时这里换回 indeterminate 才有意义)。
|
||||
//!
|
||||
//! ## 文案口径(P2-2)
|
||||
//!
|
||||
//! 只说页面真能做到的动作:说明里那句「点右上角的刷新」对应页头那个真的 Refresh 按钮
|
||||
//! (`Message::Action(Action::RefreshSources)`);没有写「稍后会自动重试」这种 UI 做不到的承诺。
|
||||
|
||||
use iced::widget::{column, container};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::feedback::{empty_state, EmptyState};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::{Action, Message};
|
||||
|
||||
/// 两个占位状态共用的外壳:水平 + 垂直都居中(见模块文档的两条理由)。
|
||||
fn centered<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 加载态:一句「正在加载」+ 一句这个页面真能做的事(点刷新)。
|
||||
pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
|
||||
centered(
|
||||
column![
|
||||
frame::text_role_centered(
|
||||
ui,
|
||||
TypeRole::TitleMedium,
|
||||
true,
|
||||
catalog.text(Key::DownloadLoading),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
frame::text_role_centered(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::DownloadLoadingHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
}
|
||||
|
||||
/// 空态(真 `EmptyState`:CTA 就是标准 Filled 按钮,走按钮家族)。
|
||||
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
|
||||
centered(empty_state(
|
||||
ui,
|
||||
EmptyState::new(
|
||||
IconKind::Download,
|
||||
catalog.text(Key::DownloadEmptyTitle),
|
||||
catalog.text(Key::DownloadEmptyBody),
|
||||
)
|
||||
.action(
|
||||
catalog.text(Key::DownloadRefresh),
|
||||
Message::Action(Action::RefreshSources),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! 下载页的判据:**快照 → 模型 → 控件树**三段各自的断言。
|
||||
//!
|
||||
//! 每条都写清「抓得住什么 / 抓不住什么」——这里只测纯转换与树能构建,**像素**由报告里的
|
||||
//! 四张图与数值表负责(同 `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`。
|
||||
/// 抓不住:快照字段在 `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, Category::All, 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::All,
|
||||
"分类是**调用方给的过滤条件**,不再从列表反推(反推正是 chip 跳回「全部」的来源)"
|
||||
);
|
||||
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 的搜索口径)。
|
||||
#[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, Category::All, 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, Category::All, 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(), Category::All, 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, Category::All, 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)
|
||||
|
||||
/// 每个 `Download*` key 都**真的被页面引用**(死 key 守门,M94 r1 / P2-6)。
|
||||
///
|
||||
/// 复审抓到的 `DownloadSelected` 就是这条要拦的东西:目录里定义了、页面没人用。所以这里把页面
|
||||
/// 源码用 `include_str!` 编进来扫一遍。**抓不住什么也说清**:反向(引用了不存在的 key)是编译错误,
|
||||
/// 不需要测试;译文对不对、两套语言都填了没由 `i18n` 自己的 `built_in_catalogs_have_every_key` 守;
|
||||
/// 它只看字面出现,用宏拼 key 名能骗过它。
|
||||
#[test]
|
||||
fn every_download_key_is_referenced_by_the_page() {
|
||||
const SOURCES: [&str; 6] = [
|
||||
include_str!("../download.rs"),
|
||||
include_str!("model.rs"),
|
||||
include_str!("rows.rs"),
|
||||
include_str!("install.rs"),
|
||||
include_str!("states.rs"),
|
||||
include_str!("demo.rs"),
|
||||
];
|
||||
|
||||
let mut checked = 0;
|
||||
for key in Key::ALL
|
||||
.iter()
|
||||
.filter(|key| key.name().starts_with("Download"))
|
||||
{
|
||||
let needle = format!("Key::{}", key.name());
|
||||
assert!(
|
||||
SOURCES.iter().any(|source| source.contains(&needle)),
|
||||
"{needle} 在目录里定义了,但下载页没有任何地方用它(死 key,删掉或接上)"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert!(
|
||||
checked > 20,
|
||||
"下载页的 key 不该被成批删掉(只查到 {checked} 条)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面级的文案全部按 `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, Category::All, Lang::En.catalog());
|
||||
let zh = Download::from_snapshot(&state, Category::All, 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('版')),
|
||||
"英文目录下不许出现中文类型词"
|
||||
);
|
||||
}
|
||||
@@ -106,6 +106,7 @@ mod settings;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use download::{Category, Download};
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
pub use model::{Detail, Instances, Meta, Model, Row};
|
||||
|
||||
@@ -141,6 +142,14 @@ pub enum Message {
|
||||
/// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做,
|
||||
/// 页面只表达意图(与组件库「不持有业务状态」同一条纪律)。
|
||||
Appearance(appearance::Change),
|
||||
/// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。
|
||||
SelectVersion(String),
|
||||
/// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。
|
||||
InstallVersion(String),
|
||||
/// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。
|
||||
CancelInstall,
|
||||
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
|
||||
SetCategory(Category),
|
||||
}
|
||||
|
||||
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
|
||||
@@ -316,7 +325,7 @@ fn render<'a>(
|
||||
|
||||
match model.screen {
|
||||
Screen::Instances => instances::view(ui, metrics, model),
|
||||
Screen::Download => download::view(ui, metrics),
|
||||
Screen::Download => download::view(ui, metrics, &model.download, unlucky_i18n::Lang::En),
|
||||
Screen::Settings => settings::view(ui, metrics, appearance),
|
||||
Screen::Logs => logs::view(ui, metrics),
|
||||
}
|
||||
@@ -353,6 +362,9 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
|
||||
if screen == Screen::Instances {
|
||||
model.instances = instances::demo();
|
||||
}
|
||||
if screen == Screen::Download {
|
||||
model.download = download::demo(unlucky_i18n::Lang::En.catalog());
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use unlucky_presenter::router::NavSection;
|
||||
|
||||
use crate::app::components::interaction::Interaction;
|
||||
|
||||
use super::Screen;
|
||||
use super::{Download, Screen};
|
||||
|
||||
/// 实例列表的一行(`ListItem` 的槽位:overline = 运行状态,headline = 名字,supporting = 版本)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -83,6 +83,8 @@ pub struct Model {
|
||||
pub active: Option<NavSection>,
|
||||
/// 实例页的数据(其它三页还是占位视觉,不读)
|
||||
pub instances: Instances,
|
||||
/// 下载页的数据(M94:版本列表 / 选中 / 安装进度,全部来自 presenter 快照)
|
||||
pub download: Download,
|
||||
/// 取证用的强制交互态(`--force-state`):`Some` 时页面里的控件按该态画。
|
||||
///
|
||||
/// M90 r2(P3-1)之前这个开关只作用到 `shell` 回退路径,产品页是**静默失效**的;
|
||||
@@ -98,6 +100,7 @@ impl Model {
|
||||
window_width,
|
||||
active,
|
||||
instances: Instances::default(),
|
||||
download: Download::loading(),
|
||||
forced: None,
|
||||
}
|
||||
}
|
||||
|
||||
+53
-160
@@ -115,6 +115,58 @@ catalog! {
|
||||
|
||||
// ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`)
|
||||
CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"),
|
||||
|
||||
// --- download page (M94) ---
|
||||
// 下载页(`crates/app-iced/src/pages/download`)。一句话一个 key:带变量的句子用 `{name}`,
|
||||
// 页面只做 `Catalog::render`,不拼片段(`bridge` 的规矩 1)。
|
||||
//
|
||||
// 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志),
|
||||
// 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。
|
||||
DownloadTitle => ("Downloads", "下载"),
|
||||
DownloadSubtitle => ("Versions you can install", "可以安装的版本"),
|
||||
DownloadRefresh => ("Refresh", "刷新"),
|
||||
DownloadInstall => ("Install", "安装"),
|
||||
DownloadCancel => ("Cancel install", "取消安装"),
|
||||
DownloadInstalled => ("Installed", "已安装"),
|
||||
DownloadCount => ("{count} versions", "{count} 个版本"),
|
||||
DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"),
|
||||
DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"),
|
||||
DownloadLoading => ("Loading versions…", "正在加载版本列表…"),
|
||||
DownloadLoadingHint => (
|
||||
"If this stays here, press Refresh in the top right.",
|
||||
"如果一直停在这里,点右上角的刷新。"
|
||||
),
|
||||
DownloadEmptyTitle => ("No versions to show", "没有可显示的版本"),
|
||||
DownloadEmptyBody => (
|
||||
"Pick another category, or refresh the list.",
|
||||
"换一个分类,或刷新一次列表。"
|
||||
),
|
||||
DownloadCategoryAll => ("All", "全部"),
|
||||
DownloadCategoryRelease => ("Release", "正式版"),
|
||||
DownloadCategorySnapshot => ("Snapshot", "预览版"),
|
||||
DownloadCategoryAncient => ("Ancient", "远古版"),
|
||||
DownloadStagePlanning => ("Planning", "解析版本"),
|
||||
DownloadStageDownloading => ("Downloading files", "下载文件"),
|
||||
DownloadStageInstalling => ("Building the version", "生成版本"),
|
||||
DownloadStageRegistering => ("Registering the instance", "登记实例"),
|
||||
DownloadStageDone => ("Done", "已完成"),
|
||||
DownloadStageFailed => ("Install failed", "安装失败"),
|
||||
DownloadStageCancelled => ("Install cancelled", "安装已取消"),
|
||||
DownloadProgress => (
|
||||
"{done}/{total} files",
|
||||
"{done}/{total} 文件"
|
||||
),
|
||||
DownloadProgressUnknown => ("Waiting for the first file…", "等第一个文件…"),
|
||||
// 速率与剩余时间是两个独立短语,各占一个 key(`DownloadRate` 只负责把它们排成一行)
|
||||
DownloadRate => ("{speed} · {eta}", "{speed},{eta}"),
|
||||
DownloadEtaSecs => ("{secs}s left", "剩 {secs}s"),
|
||||
DownloadEtaMins => ("{mins}m{secs}s left", "剩 {mins}m{secs}s"),
|
||||
DownloadFailedHint => (
|
||||
"The Logs page has the full reason.",
|
||||
"完整原因在日志页。"
|
||||
),
|
||||
DownloadOpenLogs => ("Open logs", "打开日志"),
|
||||
DownloadDismiss => ("Dismiss", "关闭"),
|
||||
}
|
||||
|
||||
/// 一套语言的目录(不可变;`Copy`,随手传值)。
|
||||
@@ -229,164 +281,5 @@ pub fn placeholders(text: &str) -> Vec<&str> {
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 手写一套**故意缺条**的目录(不经过 `catalog!`,模拟后续 mission 的注入表)。
|
||||
const PARTIAL_ZH: &[(Key, &str)] = &[
|
||||
(Key::NavInstances, "实例"),
|
||||
(Key::NavLogs, "日志"),
|
||||
(Key::NavSettings, "设置"),
|
||||
(Key::NavDownload, "下载"),
|
||||
(Key::InstancesNew, "新建实例"),
|
||||
(Key::InstancesImport, "导入"),
|
||||
(Key::InstancesBack, "返回实例列表"),
|
||||
(Key::InstancesPlay, "启动"),
|
||||
(Key::InstancesLastPlayed, "上次游玩 {when}"),
|
||||
(Key::InstancesEmptyTitle, "未选择实例"),
|
||||
(Key::InstancesEmptyBody, "选择一个实例查看详情。"),
|
||||
(Key::LoaderVanilla, "原版"),
|
||||
(Key::LaunchOutcomeNormal, "正常结束"),
|
||||
(Key::LaunchOutcomeCrash, "游戏崩溃"),
|
||||
// 缺:LaunchOutcomeSilent / LaunchFailedWithCode / FontCjk* / Settings* / CoreTextUntranslated
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn built_in_catalogs_have_every_key() {
|
||||
// 抓得住:`catalog!` 的成对生成被绕开(有人改回「两套各写一遍」并漏了一条 / 抄重了一条)。
|
||||
// 判据是**集合相等**:`Key` 枚举拦住了「表里有、枚举没有」,这里拦反方向。
|
||||
// 抓不住:译文对不对(这只能是人工 / 后续的语言审查)。
|
||||
for lang in Lang::ALL {
|
||||
let catalog = Catalog::of(*lang);
|
||||
assert_eq!(catalog.missing(), Vec::new(), "{}", catalog.lang().tag());
|
||||
assert_eq!(catalog.diagnose(), None);
|
||||
|
||||
let mut keys = catalog
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
let total = keys.len();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), total, "{} 目录里有重复条目", lang.tag());
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
Key::ALL.len(),
|
||||
"{} 条目数与 key 数不等",
|
||||
lang.tag()
|
||||
);
|
||||
|
||||
for key in Key::ALL {
|
||||
assert!(
|
||||
catalog.get(*key).is_some(),
|
||||
"{} 缺 {}",
|
||||
lang.tag(),
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!Key::ALL.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_names_are_unique_and_stable() {
|
||||
// 抓得住:同一个 key 被写成两个变体(名字重复会让日志/诊断指认失准)。
|
||||
// 抓不住:重命名变体对下游源码的影响(那是编译错误,本来就跑不过)。
|
||||
let mut names = Key::ALL.iter().map(|key| key.name()).collect::<Vec<_>>();
|
||||
let total = names.len();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), total);
|
||||
assert_eq!(Key::NavInstances.name(), "NavInstances");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_catalog_is_actually_chinese() {
|
||||
// 抓得住:整句漏译(英文被抄进中文表);一条都没有说明这张表是真翻过的。
|
||||
// 抓不住:语义翻错、语气不合(机器能查的只有「与英文不同」这一层)。
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
for key in Key::ALL {
|
||||
let english = en.text(*key);
|
||||
assert_ne!(english, zh.text(*key), "{} 未翻译", key.name());
|
||||
assert!(!english.is_empty() && !zh.text(*key).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_sets_match_across_languages() {
|
||||
// 抓得住:一处文案的 `{code}` / `{when}` 在另一套译文里丢了或写错名——
|
||||
// 那会让 render() 输出半截句子,或让参数静默失效。
|
||||
// 抓不住:占位符本身是不是正确的变量(那是调用方的职责,由页面侧测试守)。
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
for key in Key::ALL {
|
||||
let mut left = placeholders(en.text(*key));
|
||||
let mut right = placeholders(zh.text(*key));
|
||||
left.sort_unstable();
|
||||
right.sort_unstable();
|
||||
assert_eq!(left, right, "{}", key.name());
|
||||
}
|
||||
assert_eq!(placeholders("exit {code} at {when}"), vec!["code", "when"]);
|
||||
assert_eq!(placeholders("没有占位符"), Vec::<&str>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rows_are_diagnosed_in_plain_words() {
|
||||
// 抓得住:缺译诊断的**可读性**(必须点名语言、key 名与英文原文)——注入目录缺条时靠它定位。
|
||||
// 抓不住:内置目录真的缺译(内置表由 catalog! 结构保证,见上一条测试)。
|
||||
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
|
||||
assert_eq!(partial.missing().len(), Key::ALL.len() - PARTIAL_ZH.len());
|
||||
|
||||
let report = partial.diagnose().expect("应当报缺译");
|
||||
assert!(report.starts_with("zh-CN 缺 "), "{report}");
|
||||
assert!(report.contains("LaunchOutcomeSilent"), "{report}");
|
||||
assert!(
|
||||
report.contains("Suspected silent failure"),
|
||||
"诊断要带上英文原文当参考:{report}"
|
||||
);
|
||||
assert!(
|
||||
!report.contains("NavInstances"),
|
||||
"已译的 key 不该出现在报告里:{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_translation_never_falls_back_silently() {
|
||||
// 抓得住:缺译被静默顶替(回落英文 / 回落中文 / 返回空串)——那会产出「半句中文半句英文」的界面。
|
||||
// 抓不住:MISSING 标记真的被画到屏幕上(那是 UI 侧截图验收的事)。
|
||||
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
|
||||
assert_eq!(partial.get(Key::LaunchOutcomeSilent), None);
|
||||
assert_eq!(partial.text(Key::LaunchOutcomeSilent), MISSING);
|
||||
assert_eq!(
|
||||
partial.render(
|
||||
Key::LaunchFailedWithCode,
|
||||
&[("code", "0"), ("reason", "JVM 拒绝启动")]
|
||||
),
|
||||
"⟪missing:LaunchFailedWithCode⟫"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_and_keeps_unfilled_placeholders_visible() {
|
||||
// 抓得住:占位符替换的回归(漏参时**保留** `{code}` 而不是清空、不 panic)。
|
||||
// 抓不住:调用方传没传对值(`("code", "0")` 是不是真的退出码)。
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
assert_eq!(
|
||||
zh.render(
|
||||
Key::LaunchFailedWithCode,
|
||||
&[("code", "0"), ("reason", "mod 冲突")]
|
||||
),
|
||||
"启动失败(退出码 0):mod 冲突"
|
||||
);
|
||||
assert_eq!(
|
||||
zh.render(Key::LaunchFailedWithCode, &[("code", "0")]),
|
||||
"启动失败(退出码 0):{reason}"
|
||||
);
|
||||
assert_eq!(zh.text(Key::InstancesNew), "新建实例");
|
||||
assert_eq!(zh.lang(), Lang::ZhCn);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,162 @@
|
||||
//! `Catalog` 的守门测试(M94 从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限,
|
||||
//! 而页面每加一批 key 都要在这个模块里占一行)。
|
||||
//!
|
||||
//! **零改动搬运**:用例、注释、断言一字未动;`use super::*` 指向的仍是 `crate::catalog`。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 手写一套**故意缺条**的目录(不经过 `catalog!`,模拟后续 mission 的注入表)。
|
||||
const PARTIAL_ZH: &[(Key, &str)] = &[
|
||||
(Key::NavInstances, "实例"),
|
||||
(Key::NavLogs, "日志"),
|
||||
(Key::NavSettings, "设置"),
|
||||
(Key::NavDownload, "下载"),
|
||||
(Key::InstancesNew, "新建实例"),
|
||||
(Key::InstancesImport, "导入"),
|
||||
(Key::InstancesBack, "返回实例列表"),
|
||||
(Key::InstancesPlay, "启动"),
|
||||
(Key::InstancesLastPlayed, "上次游玩 {when}"),
|
||||
(Key::InstancesEmptyTitle, "未选择实例"),
|
||||
(Key::InstancesEmptyBody, "选择一个实例查看详情。"),
|
||||
(Key::LoaderVanilla, "原版"),
|
||||
(Key::LaunchOutcomeNormal, "正常结束"),
|
||||
(Key::LaunchOutcomeCrash, "游戏崩溃"),
|
||||
// 缺:LaunchOutcomeSilent / LaunchFailedWithCode / FontCjk* / Settings* / CoreTextUntranslated
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn built_in_catalogs_have_every_key() {
|
||||
// 抓得住:`catalog!` 的成对生成被绕开(有人改回「两套各写一遍」并漏了一条 / 抄重了一条)。
|
||||
// 判据是**集合相等**:`Key` 枚举拦住了「表里有、枚举没有」,这里拦反方向。
|
||||
// 抓不住:译文对不对(这只能是人工 / 后续的语言审查)。
|
||||
for lang in Lang::ALL {
|
||||
let catalog = Catalog::of(*lang);
|
||||
assert_eq!(catalog.missing(), Vec::new(), "{}", catalog.lang().tag());
|
||||
assert_eq!(catalog.diagnose(), None);
|
||||
|
||||
let mut keys = catalog
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
let total = keys.len();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), total, "{} 目录里有重复条目", lang.tag());
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
Key::ALL.len(),
|
||||
"{} 条目数与 key 数不等",
|
||||
lang.tag()
|
||||
);
|
||||
|
||||
for key in Key::ALL {
|
||||
assert!(
|
||||
catalog.get(*key).is_some(),
|
||||
"{} 缺 {}",
|
||||
lang.tag(),
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!Key::ALL.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_names_are_unique_and_stable() {
|
||||
// 抓得住:同一个 key 被写成两个变体(名字重复会让日志/诊断指认失准)。
|
||||
// 抓不住:重命名变体对下游源码的影响(那是编译错误,本来就跑不过)。
|
||||
let mut names = Key::ALL.iter().map(|key| key.name()).collect::<Vec<_>>();
|
||||
let total = names.len();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), total);
|
||||
assert_eq!(Key::NavInstances.name(), "NavInstances");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_catalog_is_actually_chinese() {
|
||||
// 抓得住:整句漏译(英文被抄进中文表);一条都没有说明这张表是真翻过的。
|
||||
// 抓不住:语义翻错、语气不合(机器能查的只有「与英文不同」这一层)。
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
for key in Key::ALL {
|
||||
let english = en.text(*key);
|
||||
assert_ne!(english, zh.text(*key), "{} 未翻译", key.name());
|
||||
assert!(!english.is_empty() && !zh.text(*key).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_sets_match_across_languages() {
|
||||
// 抓得住:一处文案的 `{code}` / `{when}` 在另一套译文里丢了或写错名——
|
||||
// 那会让 render() 输出半截句子,或让参数静默失效。
|
||||
// 抓不住:占位符本身是不是正确的变量(那是调用方的职责,由页面侧测试守)。
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
for key in Key::ALL {
|
||||
let mut left = placeholders(en.text(*key));
|
||||
let mut right = placeholders(zh.text(*key));
|
||||
left.sort_unstable();
|
||||
right.sort_unstable();
|
||||
assert_eq!(left, right, "{}", key.name());
|
||||
}
|
||||
assert_eq!(placeholders("exit {code} at {when}"), vec!["code", "when"]);
|
||||
assert_eq!(placeholders("没有占位符"), Vec::<&str>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rows_are_diagnosed_in_plain_words() {
|
||||
// 抓得住:缺译诊断的**可读性**(必须点名语言、key 名与英文原文)——注入目录缺条时靠它定位。
|
||||
// 抓不住:内置目录真的缺译(内置表由 catalog! 结构保证,见上一条测试)。
|
||||
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
|
||||
assert_eq!(partial.missing().len(), Key::ALL.len() - PARTIAL_ZH.len());
|
||||
|
||||
let report = partial.diagnose().expect("应当报缺译");
|
||||
assert!(report.starts_with("zh-CN 缺 "), "{report}");
|
||||
assert!(report.contains("LaunchOutcomeSilent"), "{report}");
|
||||
assert!(
|
||||
report.contains("Suspected silent failure"),
|
||||
"诊断要带上英文原文当参考:{report}"
|
||||
);
|
||||
assert!(
|
||||
!report.contains("NavInstances"),
|
||||
"已译的 key 不该出现在报告里:{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_translation_never_falls_back_silently() {
|
||||
// 抓得住:缺译被静默顶替(回落英文 / 回落中文 / 返回空串)——那会产出「半句中文半句英文」的界面。
|
||||
// 抓不住:MISSING 标记真的被画到屏幕上(那是 UI 侧截图验收的事)。
|
||||
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
|
||||
assert_eq!(partial.get(Key::LaunchOutcomeSilent), None);
|
||||
assert_eq!(partial.text(Key::LaunchOutcomeSilent), MISSING);
|
||||
assert_eq!(
|
||||
partial.render(
|
||||
Key::LaunchFailedWithCode,
|
||||
&[("code", "0"), ("reason", "JVM 拒绝启动")]
|
||||
),
|
||||
"⟪missing:LaunchFailedWithCode⟫"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_and_keeps_unfilled_placeholders_visible() {
|
||||
// 抓得住:占位符替换的回归(漏参时**保留** `{code}` 而不是清空、不 panic)。
|
||||
// 抓不住:调用方传没传对值(`("code", "0")` 是不是真的退出码)。
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
assert_eq!(
|
||||
zh.render(
|
||||
Key::LaunchFailedWithCode,
|
||||
&[("code", "0"), ("reason", "mod 冲突")]
|
||||
),
|
||||
"启动失败(退出码 0):mod 冲突"
|
||||
);
|
||||
assert_eq!(
|
||||
zh.render(Key::LaunchFailedWithCode, &[("code", "0")]),
|
||||
"启动失败(退出码 0):{reason}"
|
||||
);
|
||||
assert_eq!(zh.text(Key::InstancesNew), "新建实例");
|
||||
assert_eq!(zh.lang(), Lang::ZhCn);
|
||||
}
|
||||
Reference in new issue
Block a user