Merge branch 'feat/m120-insti18n-instance-page-and-stage-la'
This commit is contained in:
commit
7235cf08e4
17 files changed
+1069
-174
No files matched your search
@@ -8,6 +8,7 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
@@ -159,9 +160,11 @@ fn launching_goes_through_the_backend_and_comes_back_as_events() {
|
||||
!detail.can_launch && detail.can_stop,
|
||||
"派发 LaunchInstance 后后端发回了 Busy 事件:按钮组合必须已经翻转"
|
||||
);
|
||||
// 阶段名跟界面语言走(M120):断言对着**当前生效语言**的目录算,别在中文机器上钉死英文
|
||||
let stage = app.lang().catalog().text(Key::InstancesStageDownload);
|
||||
assert_eq!(
|
||||
detail.status.as_deref(),
|
||||
Some("Downloading · 0%"),
|
||||
Some(format!("{stage} · 0%").as_str()),
|
||||
"状态行来自事件里的进度,不是本地假造"
|
||||
);
|
||||
}
|
||||
@@ -185,8 +188,13 @@ fn ticks_advance_the_backend_script() {
|
||||
|
||||
let detail = app.view.instances.detail.as_ref().expect("有选中项");
|
||||
let status = detail.status.as_deref().unwrap_or_default();
|
||||
let catalog = app.lang().catalog();
|
||||
let (verify, launch) = (
|
||||
catalog.text(Key::InstancesStageVerify),
|
||||
catalog.text(Key::InstancesStageLaunch),
|
||||
);
|
||||
assert!(
|
||||
status.starts_with("Verifying") || status.starts_with("Starting"),
|
||||
status.starts_with(verify) || status.starts_with(launch),
|
||||
"50 拍之后应已过下载阶段,实际 {status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
+15
-100
@@ -1,17 +1,19 @@
|
||||
//! `view`:presenter 快照 → 页面视图模型 → 控件树。
|
||||
//!
|
||||
//! 展示转换(快照字段 → 显示文本/网格)**全部在这里**,页面层只认 [`pages::Model`]。
|
||||
//!
|
||||
//! 实例页那一段(`1.20.4 · Fabric`、运行状态、元信息卡)M120 搬进 [`instances`]:本文件
|
||||
//! 当时贴着 400 行硬线,而 i18n 还要在这个函数里接一批阶段标签。
|
||||
|
||||
use iced::{Element, Theme};
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage};
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::cli::Screen;
|
||||
use crate::pages::{self, Detail, Meta, Row};
|
||||
use crate::pages::{self, Row};
|
||||
use crate::shell::{self, agent, NavItem};
|
||||
|
||||
use super::message::{nav_item_of, Message};
|
||||
@@ -19,9 +21,11 @@ use super::state::{log_first_frame, App};
|
||||
use super::APP_TITLE;
|
||||
|
||||
mod account_page;
|
||||
mod instances;
|
||||
mod logs;
|
||||
mod rail;
|
||||
|
||||
use instances::{detail_of, runtime_status, supporting_line};
|
||||
use logs::logs_model;
|
||||
|
||||
impl App {
|
||||
@@ -140,6 +144,8 @@ pub(super) fn model(
|
||||
) -> pages::Model {
|
||||
let route = state.route().clone();
|
||||
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
|
||||
// 实例页的文案真源:`App::lang` 的目录(M120)。列表 overline / 元信息标签 / 空态都从它取。
|
||||
let catalog = lang.catalog();
|
||||
|
||||
let rows: Vec<Row> = state
|
||||
.tiles()
|
||||
@@ -147,8 +153,8 @@ pub(super) fn model(
|
||||
.map(|cell| Row {
|
||||
id: cell.instance.id.clone(),
|
||||
name: cell.instance.name.clone(),
|
||||
supporting: supporting_line(cell.instance),
|
||||
status: runtime_status(&cell.instance.runtime),
|
||||
supporting: supporting_line(cell.instance, catalog),
|
||||
status: runtime_status(&cell.instance.runtime, catalog),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -157,7 +163,7 @@ pub(super) fn model(
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
.map(detail_of),
|
||||
.map(|instance| detail_of(instance, catalog)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -180,101 +186,10 @@ pub(super) fn model(
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版」,见 `LoaderInfo::badge`)。
|
||||
fn supporting_line(instance: &InstanceSummary) -> String {
|
||||
format!("{} · {}", instance.mc_version, instance.loader.badge())
|
||||
}
|
||||
|
||||
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
||||
fn runtime_status(runtime: &InstanceRuntime) -> Option<String> {
|
||||
match runtime {
|
||||
InstanceRuntime::Idle => None,
|
||||
InstanceRuntime::Busy { stage, progress } => Some(format!(
|
||||
"{} · {:.0}%",
|
||||
stage_label(*stage),
|
||||
progress.ratio() * 100.0
|
||||
)),
|
||||
InstanceRuntime::Running { pid } => Some(format!("Running · pid {pid}")),
|
||||
InstanceRuntime::Failed { reason } => Some(format!("Launch failed · {reason}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段文案(英文基线;i18n mission 接手后统一走词表)。
|
||||
///
|
||||
/// 不复用 `LaunchStage::label()`:那是中文,而本 crate 的界面文案在 i18n 之前是英文基线
|
||||
/// (见 `pages/instances/data.rs` 的同一条理由:干净 CI 上没有中文字体)。
|
||||
fn stage_label(stage: LaunchStage) -> &'static str {
|
||||
match stage {
|
||||
LaunchStage::Download => "Downloading",
|
||||
LaunchStage::Verify => "Verifying",
|
||||
LaunchStage::Launch => "Starting",
|
||||
LaunchStage::Window => "Waiting for window",
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
|
||||
fn detail_of(instance: &InstanceSummary) -> Detail {
|
||||
// 视觉 P2-2(M90 r2):列表行与详情曾经「同物两名」(列表 `badge()` 出「原版」、详情硬编码
|
||||
// 「Vanilla」)。现在两边都走 `LoaderInfo::badge()` 这一个入口;等 i18n 落地后由词表统一,
|
||||
// 届时两处会同时变——这正是要的。
|
||||
let loader = instance.loader.badge();
|
||||
|
||||
Detail {
|
||||
id: instance.id.clone(),
|
||||
name: instance.name.clone(),
|
||||
subtitle: supporting_line(instance),
|
||||
meta: vec![
|
||||
meta("VERSION", &instance.mc_version),
|
||||
meta("LOADER", &loader),
|
||||
meta("ISOLATION", &instance.isolation),
|
||||
meta("VERSION ID", &instance.version_id),
|
||||
meta(
|
||||
"FILES",
|
||||
if instance.version_present {
|
||||
"Present on disk"
|
||||
} else {
|
||||
"Missing"
|
||||
},
|
||||
),
|
||||
meta("LAST PLAYED", &last_played(instance.last_played.as_deref())),
|
||||
],
|
||||
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
|
||||
note: None,
|
||||
status: runtime_status(&instance.runtime),
|
||||
can_launch: matches!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
|
||||
),
|
||||
can_stop: matches!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn meta(label: &str, value: &str) -> Meta {
|
||||
Meta {
|
||||
label: label.to_string(),
|
||||
value: value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
|
||||
///
|
||||
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等 i18n/时间格式 mission。
|
||||
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
|
||||
pub(super) fn last_played(raw: Option<&str>) -> String {
|
||||
let Some(text) = raw else {
|
||||
return "Never".to_string();
|
||||
};
|
||||
match (text.get(0..10), text.get(11..16)) {
|
||||
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
|
||||
format!("{date} {time} UTC")
|
||||
}
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "view/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// 测试只借这一条(其余三条 `model()` 自己用)。
|
||||
#[cfg(test)]
|
||||
use instances::last_played;
|
||||
@@ -0,0 +1,162 @@
|
||||
//! 实例页的**展示转换**(M120 从 `app/view.rs` 原样搬出:那边给 i18n 让出行数)。
|
||||
//!
|
||||
//! | 函数 | 产物 | 谁读 |
|
||||
//! |---|---|---|
|
||||
//! | [`supporting_line`] | 列表行 / 详情副标题 `1.20.4 · Fabric 0.16.5` | [`crate::pages::Row`] / [`crate::pages::Detail`] |
|
||||
//! | [`runtime_status`] | 运行状态行(列表 overline + 详情状态行共用一份) | 同上 |
|
||||
//! | [`detail_of`] | 详情列的全部字段 | [`crate::pages::Detail`] |
|
||||
//! | [`last_played`] | `2026-10-29 19:22 UTC` / `Never` | 元信息卡 `LAST PLAYED` |
|
||||
//!
|
||||
//! 这里只做「快照字段 → 要画什么」,不碰控件树。**文案的语言由调用方的 [`Catalog`] 决定**
|
||||
//! (`view::model` 把 `App::lang` 的目录传进来),不再是 M90 时期的英文基线字面量。
|
||||
//!
|
||||
//! ## 两条接法上的取舍
|
||||
//!
|
||||
//! 1. **原版徽标走词表**:`LoaderInfo::badge()` 对原版返回**中文**「原版」(那是 presenter 的
|
||||
//! 面向中文日志的 `label()`),英文界面里直接画它就是「中文漏进英文界面」。所以原版这一支
|
||||
//! 改走 [`Key::LoaderVanilla`],装加载器时仍是 `Fabric 0.16.5`(品牌 + 版本号,不翻译);
|
||||
//! 2. **运行状态只翻标签本体**:`Running · pid 1234` → `运行中 · pid 1234`,`pid` 与数字、
|
||||
//! `·` 分隔符一个字都不动([`Key::InstancesRunning`] 的占位符只有 `{pid}`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{InstanceRuntime, InstanceSummary, LaunchStage, LoaderKind};
|
||||
|
||||
use crate::pages::{Detail, Meta};
|
||||
|
||||
/// 列表行的副文案:`1.20.4 · Fabric 0.16.5`(原版只写「原版 / Vanilla」,见 [`loader_badge`])。
|
||||
pub(super) fn supporting_line(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
format!(
|
||||
"{} · {}",
|
||||
instance.mc_version,
|
||||
loader_badge(instance, catalog)
|
||||
)
|
||||
}
|
||||
|
||||
/// 加载器徽标:原版走词表,其余交给 `LoaderInfo::badge()`(品牌 + 版本号,原样)。
|
||||
///
|
||||
/// **判据只能是「加载器类型本身是原版」**。曾经写成「原版 **或** 版本号为空」⇒
|
||||
/// `1.20.1-forge` / `1.20.1-neoforge` / `1.20.1-OptiFine` 这类**取不到版本段**的 id(`badge()`
|
||||
/// 对它们给的就是品牌名 `Forge` / `NeoForge` / `OptiFine`)会被显示成「原版 / Vanilla」——
|
||||
/// 那是 M120 r1 的 p2-1 回归,`the_loader_badge_keeps_the_brand_when_the_version_is_missing` 守着。
|
||||
fn loader_badge(instance: &InstanceSummary, catalog: Catalog) -> String {
|
||||
match instance.loader.kind {
|
||||
// 唯一需要过词表的一支:`LoaderKind::label()` 对原版返回的是**中文**「原版」
|
||||
LoaderKind::Vanilla => catalog.text(Key::LoaderVanilla).to_string(),
|
||||
// 其余品牌名不翻译;版本号为空时 `badge()` 自己就只给品牌名
|
||||
_ => instance.loader.badge(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 隔离档位:`isolated` / `shared` 是两个稳定 id(`core::game::IsolationMode::as_str`),
|
||||
/// 直接上屏就是英文界面漏中文、中文界面漏英文。只认这两档,**其余原样**(`inherit` / `custom` /
|
||||
/// 未来新增的值不许在这里被猜成一个意思)。
|
||||
fn isolation(raw: &str, catalog: Catalog) -> String {
|
||||
match raw {
|
||||
"isolated" => catalog.text(Key::InstancesIsolationIsolated).to_string(),
|
||||
"shared" => catalog.text(Key::InstancesIsolationShared).to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行状态(列表行的 overline 与详情页的状态行共用一份文案)。
|
||||
pub(super) fn runtime_status(runtime: &InstanceRuntime, catalog: Catalog) -> Option<String> {
|
||||
match runtime {
|
||||
InstanceRuntime::Idle => None,
|
||||
// 「阶段 · 百分比」:百分比是数字,不过词表;只有阶段名换语言
|
||||
InstanceRuntime::Busy { stage, progress } => Some(format!(
|
||||
"{} · {:.0}%",
|
||||
stage_label(*stage, catalog),
|
||||
progress.ratio() * 100.0
|
||||
)),
|
||||
InstanceRuntime::Running { pid } => {
|
||||
Some(catalog.render(Key::InstancesRunning, &[("pid", &pid.to_string())]))
|
||||
}
|
||||
// `reason` 是 core 给的诊断原文(**数据**,不翻译);只有前半句是界面文案
|
||||
InstanceRuntime::Failed { reason } => {
|
||||
Some(catalog.render(Key::InstancesFailed, &[("reason", reason)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段文案:**不**复用 `LaunchStage::label()`(那是核心层的中文诊断串,英文界面会漏中文)。
|
||||
fn stage_label(stage: LaunchStage, catalog: Catalog) -> &'static str {
|
||||
catalog.text(match stage {
|
||||
LaunchStage::Download => Key::InstancesStageDownload,
|
||||
LaunchStage::Verify => Key::InstancesStageVerify,
|
||||
LaunchStage::Launch => Key::InstancesStageLaunch,
|
||||
LaunchStage::Window => Key::InstancesStageWindow,
|
||||
})
|
||||
}
|
||||
|
||||
/// 详情列的内容(字段全部来自 [`InstanceSummary`])。
|
||||
pub(super) fn detail_of(instance: &InstanceSummary, catalog: Catalog) -> Detail {
|
||||
Detail {
|
||||
id: instance.id.clone(),
|
||||
name: instance.name.clone(),
|
||||
subtitle: supporting_line(instance, catalog),
|
||||
meta: vec![
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaVersion),
|
||||
&instance.mc_version,
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaLoader),
|
||||
&loader_badge(instance, catalog),
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaIsolation),
|
||||
&isolation(&instance.isolation, catalog),
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaVersionId),
|
||||
&instance.version_id,
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaFiles),
|
||||
catalog.text(if instance.version_present {
|
||||
Key::InstancesPresentOnDisk
|
||||
} else {
|
||||
Key::InstancesMissing
|
||||
}),
|
||||
),
|
||||
meta(
|
||||
catalog.text(Key::InstancesMetaLastPlayed),
|
||||
&last_played(instance.last_played.as_deref(), catalog),
|
||||
),
|
||||
],
|
||||
// 真快照没有「正文描述」这种字段(偏离 #9):演示数据才有正文段
|
||||
note: None,
|
||||
status: runtime_status(&instance.runtime, catalog),
|
||||
can_launch: matches!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Idle | InstanceRuntime::Failed { .. }
|
||||
),
|
||||
can_stop: matches!(
|
||||
instance.runtime,
|
||||
InstanceRuntime::Busy { .. } | InstanceRuntime::Running { .. }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn meta(label: &str, value: &str) -> Meta {
|
||||
Meta {
|
||||
label: label.to_string(),
|
||||
value: value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO-8601(`2026-10-29T19:22:00Z`)→ `2026-10-29 19:22 UTC`;`None` = 从未启动。
|
||||
///
|
||||
/// 只做切片不做时区换算:core 给的是 UTC,`Z` 就是它的时区;本地时区换算等时间格式 mission。
|
||||
/// 非 ASCII 或长度不足时原样返回(不 panic、不猜)。
|
||||
pub(super) fn last_played(raw: Option<&str>, catalog: Catalog) -> String {
|
||||
let Some(text) = raw else {
|
||||
return catalog.text(Key::InstancesNeverPlayed).to_string();
|
||||
};
|
||||
match (text.get(0..10), text.get(11..16)) {
|
||||
(Some(date), Some(time)) if date.starts_with(|c: char| c.is_ascii_digit()) => {
|
||||
format!("{date} {time} UTC")
|
||||
}
|
||||
_ => text.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
//! `app/view.rs` 的守门测试(rebase 到 5adac23 之后 view.rs 贴 400 行上限,按「测试独立成文件」拆出)。
|
||||
|
||||
use super::*;
|
||||
use unlucky_presenter::dto::{InstanceSummary, LoaderInfo, LoaderKind, Progress};
|
||||
use unlucky_i18n::Key;
|
||||
use unlucky_presenter::dto::{
|
||||
InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
|
||||
};
|
||||
use unlucky_presenter::mock_data::mock_instances;
|
||||
|
||||
fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
@@ -28,9 +31,9 @@ fn instance(runtime: InstanceRuntime) -> InstanceSummary {
|
||||
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
|
||||
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
|
||||
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
||||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||||
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
|
||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿 / **给定语言**。
|
||||
fn at_lang(state: &AppState, forced: Option<crate::cli::ForcedState>, lang: Lang) -> pages::Model {
|
||||
let draft = pages::InstallDraft::default();
|
||||
let page = pages::content::PageState::default();
|
||||
model(
|
||||
state,
|
||||
@@ -43,6 +46,11 @@ fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model
|
||||
)
|
||||
}
|
||||
|
||||
/// 同 [`at_lang`],语言取英文(绝大多数断言只关心「快照 → 模型」的映射,不关心译文)。
|
||||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||||
at_lang(state, forced, Lang::En)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_forced_state_maps_to_the_expected_interaction() {
|
||||
let state = AppState::new();
|
||||
@@ -119,22 +127,185 @@ fn failed_instances_can_be_retried() {
|
||||
reason: "exit 1".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
runtime_status(&failed.runtime).as_deref(),
|
||||
runtime_status(&failed.runtime, Lang::En.catalog()).as_deref(),
|
||||
Some("Launch failed · exit 1")
|
||||
);
|
||||
assert!(detail_of(&failed).can_launch, "失败后要能重试");
|
||||
assert!(
|
||||
detail_of(&failed, Lang::En.catalog()).can_launch,
|
||||
"失败后要能重试"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_sliced_not_guessed() {
|
||||
let en = Lang::En.catalog();
|
||||
assert_eq!(
|
||||
last_played(Some("2026-10-29T19:22:00Z")),
|
||||
last_played(Some("2026-10-29T19:22:00Z"), en),
|
||||
"2026-10-29 19:22 UTC"
|
||||
);
|
||||
assert_eq!(last_played(None), "Never");
|
||||
assert_eq!(last_played(None, en), "Never");
|
||||
// 短串 / 非 ASCII 原样返回(不 panic)
|
||||
assert_eq!(last_played(Some("2026-10")), "2026-10");
|
||||
assert_eq!(last_played(Some("昨天")), "昨天");
|
||||
assert_eq!(last_played(Some("2026-10"), en), "2026-10");
|
||||
assert_eq!(last_played(Some("昨天"), en), "昨天");
|
||||
// 「从未游玩」是唯一走词表的那一支
|
||||
assert_eq!(last_played(None, Lang::ZhCn.catalog()), "从未游玩");
|
||||
}
|
||||
|
||||
/// **快照 → 页面文案跟着 `App::lang` 走**(M120 的核心接线断言,模型这一层)。
|
||||
///
|
||||
/// 抓得住:`view::model` 又把语言写死成 `Lang::En`(M106 之前的原样)、阶段/元信息标签还留着
|
||||
/// 英文基线字面量、原版徽标直接画 presenter 的中文「原版」。这三种都不会让任何别的测试红。
|
||||
/// 抓不住:页面把 key 画没画上屏(那是 `pages/instances/tests/i18n.rs` 的取样口 + 出图)。
|
||||
#[test]
|
||||
fn the_runtime_lines_and_meta_labels_follow_the_language() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![
|
||||
instance(InstanceRuntime::Running { pid: 1234 }),
|
||||
instance(InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Verify,
|
||||
progress: Progress {
|
||||
done: 750,
|
||||
total: 1_500,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let en = at_lang(&state, None, Lang::En);
|
||||
let zh = at_lang(&state, None, Lang::ZhCn);
|
||||
|
||||
// 列表 overline:中英各一份,数字与分隔符不动
|
||||
assert_eq!(
|
||||
en.instances.rows[0].status.as_deref(),
|
||||
Some("Running · pid 1234")
|
||||
);
|
||||
assert_eq!(
|
||||
zh.instances.rows[0].status.as_deref(),
|
||||
Some("运行中 · pid 1234")
|
||||
);
|
||||
assert_eq!(
|
||||
en.instances.rows[1].status.as_deref(),
|
||||
Some("Verifying · 50%")
|
||||
);
|
||||
assert_eq!(zh.instances.rows[1].status.as_deref(), Some("校验中 · 50%"));
|
||||
|
||||
// 元信息卡的标签与值
|
||||
let (en_detail, zh_detail) = (
|
||||
en.instances.detail.expect("有详情"),
|
||||
zh.instances.detail.expect("有详情"),
|
||||
);
|
||||
let labels = |detail: &pages::Detail| -> Vec<String> {
|
||||
detail.meta.iter().map(|cell| cell.label.clone()).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
labels(&en_detail),
|
||||
vec![
|
||||
"VERSION",
|
||||
"LOADER",
|
||||
"ISOLATION",
|
||||
"VERSION ID",
|
||||
"FILES",
|
||||
"LAST PLAYED"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
labels(&zh_detail),
|
||||
vec!["版本", "加载器", "隔离方式", "版本标识", "文件", "上次游玩"]
|
||||
);
|
||||
|
||||
// 值里只有「是否在磁盘上」与「上次游玩」是可翻译的;版本号 / 隔离方式 / 版本标识是数据
|
||||
// 隔离档位是 core 的稳定 id(`isolated` / `shared`),也要换成人话;认不出来的原样上屏
|
||||
assert_eq!(en_detail.meta[2].value, "Isolated");
|
||||
assert_eq!(zh_detail.meta[2].value, "独立目录");
|
||||
assert_eq!(en_detail.meta[4].value, "Present on disk");
|
||||
assert_eq!(zh_detail.meta[4].value, "已在磁盘上");
|
||||
assert_eq!(
|
||||
en_detail.meta[5].value, "2026-10-29 19:22 UTC",
|
||||
"时间戳是数据:只切片,不随语言变形"
|
||||
);
|
||||
assert_eq!(zh_detail.meta[5].value, "2026-10-29 19:22 UTC");
|
||||
|
||||
// 详情状态行与列表 overline 同源
|
||||
assert_eq!(zh_detail.status.as_deref(), Some("运行中 · pid 1234"));
|
||||
}
|
||||
|
||||
/// 原版徽标:`LoaderInfo::badge()` 对原版返回**中文**「原版」,英文界面里必须换回 `Vanilla`。
|
||||
///
|
||||
/// 抓得住:`loader_badge` 退回直接调 `badge()`(M90 ~ M119 的形状)——那样英文界面会漏中文,
|
||||
/// 而中文界面又是对的,所以只有这条双语断言抓得住它。
|
||||
#[test]
|
||||
fn the_vanilla_badge_follows_the_language() {
|
||||
let mut bare = instance(InstanceRuntime::Idle);
|
||||
bare.loader = LoaderInfo {
|
||||
kind: LoaderKind::Vanilla,
|
||||
version: String::new(),
|
||||
};
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![bare]),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
let en = at_lang(&state, None, Lang::En);
|
||||
let zh = at_lang(&state, None, Lang::ZhCn);
|
||||
assert_eq!(en.instances.rows[0].supporting, "1.20.4 · Vanilla");
|
||||
assert_eq!(zh.instances.rows[0].supporting, "1.20.4 · 原版");
|
||||
}
|
||||
|
||||
/// **品牌名不许被「版本号为空」吞掉**(M120 r1 复审的 p2-1 回归)。
|
||||
///
|
||||
/// 背景:`host::game::loader_from_id("1.20.1-forge")` 这类 id 里取不到版本段(它按 `forge-`
|
||||
/// 找,而 id 的写法是 `-forge` 结尾),于是 `LoaderInfo { kind: Forge, version: "" }`。
|
||||
/// `LoaderInfo::badge()` 对这种输入给的正是品牌名 `Forge`——所以判据只能是「类型是原版」,
|
||||
/// 不能是「版本号为空」。
|
||||
///
|
||||
/// 三个 id 各一条断言(复审点名的三档);`Vanilla` 那一支由
|
||||
/// `the_vanilla_badge_follows_the_language` 守,两条合起来把 `loader_badge` 的两侧都钉住。
|
||||
///
|
||||
/// 抓得住:`loader_badge` 把「非原版 + 空版本号」也送进 `Key::LoaderVanilla`
|
||||
/// (r1 的真实形状:`left: "1.20.4 · Vanilla" / right: "1.20.4 · Forge"`);
|
||||
/// 抓不住:`loader_from_id` 认不出自家 `-fabric-` / `-quilt-` 口径这个**更老**的 bug
|
||||
/// (在 presenter 里,另一条 mission)。
|
||||
#[test]
|
||||
fn the_loader_badge_keeps_the_brand_when_the_version_is_missing() {
|
||||
// (版本 id, 期望徽标) —— id 形状取自 `host::game::loader_from_id` 的三条真实输入
|
||||
let cases = [
|
||||
("1.20.1-forge", LoaderKind::Forge, "Forge"),
|
||||
("1.20.1-neoforge", LoaderKind::NeoForge, "NeoForge"),
|
||||
("1.20.1-OptiFine", LoaderKind::OptiFine, "OptiFine"),
|
||||
];
|
||||
|
||||
for (id, kind, brand) in cases {
|
||||
let mut one = instance(InstanceRuntime::Idle);
|
||||
one.loader = LoaderInfo {
|
||||
kind,
|
||||
// `loader_from_id` 对这三条 id 取不到版本段 ⇒ 空串
|
||||
version: String::new(),
|
||||
};
|
||||
let mut state = AppState::new();
|
||||
state.apply(unlucky_presenter::dto::AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstancesChanged(vec![one]),
|
||||
));
|
||||
state.navigate(Route::Detail("survival".to_string()));
|
||||
|
||||
// 品牌名两套语言都不翻译 ⇒ 中英逐字相同
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let model = at_lang(&state, None, lang);
|
||||
assert_eq!(
|
||||
model.instances.rows[0].supporting,
|
||||
format!("1.20.4 · {brand}"),
|
||||
"{id} 的徽标被显示成了别的东西(新增的判据「版本号为空 ⇒ 原版」就是这条要拦的)"
|
||||
);
|
||||
let detail = model.instances.detail.expect("有详情");
|
||||
assert_eq!(
|
||||
detail.meta[1].label,
|
||||
lang.catalog().text(Key::InstancesMetaLoader)
|
||||
);
|
||||
assert_eq!(detail.meta[1].value, brand, "{id} 的 LOADER 卡值");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
mod data;
|
||||
mod demo;
|
||||
mod detail;
|
||||
mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -55,6 +56,7 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use data::{DEMO, NOTE, SELECTED};
|
||||
pub(super) use demo::demo;
|
||||
pub(super) use text::Texts;
|
||||
|
||||
use iced::widget::{column, container, row, scrollable};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
@@ -92,22 +94,26 @@ pub(super) const ROW_SPACING: Option<Space> = None;
|
||||
pub(super) const LIST_BOTTOM_INSET: Space = Space::X2;
|
||||
|
||||
/// 整页(`block.detail = None` 时详情区显示空态)。
|
||||
///
|
||||
/// 文案全部走 [`Texts`](`model.lang` 的语言):这个函数是实例页唯一的取词入口,
|
||||
/// 页面里不留任何可见英文字面量(守门见 `instances/tests/i18n.rs`)。
|
||||
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<'a, Message> {
|
||||
let block = &model.instances;
|
||||
let forced = model.forced;
|
||||
let header = Header::new("Instances")
|
||||
.subtitle("Local versions and their mod loaders")
|
||||
let texts = Texts::of(model.lang.catalog());
|
||||
let header = Header::new(texts.title())
|
||||
.subtitle(texts.subtitle())
|
||||
.action(buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("New instance", Message::Action(Action::NewInstance)),
|
||||
Button::new(texts.new_instance(), Message::Action(Action::NewInstance)),
|
||||
forced,
|
||||
),
|
||||
))
|
||||
.action(buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("Import", Message::Action(Action::Import)),
|
||||
Button::new(texts.import(), Message::Action(Action::Import)),
|
||||
forced,
|
||||
),
|
||||
));
|
||||
@@ -115,15 +121,15 @@ pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics, model: &'a Model) -> Element<
|
||||
let body = match metrics.layout {
|
||||
PageLayout::ListDetail => frame::panes(
|
||||
metrics,
|
||||
list_column(ui, block, forced),
|
||||
list_column(ui, block, forced, texts),
|
||||
match &block.detail {
|
||||
Some(detail) => detail::detail_column(ui, metrics, detail, forced),
|
||||
None => detail::detail_empty(ui),
|
||||
Some(detail) => detail::detail_column(ui, metrics, detail, forced, texts),
|
||||
None => detail::detail_empty(ui, texts),
|
||||
},
|
||||
),
|
||||
_ => match &block.detail {
|
||||
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced),
|
||||
None => frame::body(metrics, detail::detail_empty(ui)),
|
||||
Some(detail) => detail::narrow_detail(ui, metrics, detail, forced, texts),
|
||||
None => frame::body(metrics, detail::detail_empty(ui, texts)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -136,6 +142,7 @@ fn list_column<'a>(
|
||||
ui: &'a Ui,
|
||||
block: &'a Instances,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
let head = container(
|
||||
row![
|
||||
@@ -143,7 +150,7 @@ fn list_column<'a>(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
"ALL INSTANCES",
|
||||
texts.all_title(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
//! 实例页的**演示数据**(骨架期没有业务状态机,也没有业务数据)。
|
||||
//!
|
||||
//! 12 条,**故意比可视区高**:列表列的独立滚动条要能在截图里看见(单测钉住这条)。
|
||||
//! 文案是英文基线:i18n 是后续 mission,干净 CI 上没有中文字体,写中文会变豆腐块。
|
||||
//!
|
||||
//! **这里的数据是夹具(用户数据),不是界面文案**:`1.20.4` / `Fabric 0.15.7` / `4 GiB` /
|
||||
//! `2 hours ago` 这些是「实例清单里会长成什么样」的样本,M120 没有把它们搬进词表——它们不是
|
||||
//! 界面说的那句话。界面文案(标题 / 按钮 / 元信息标签 / 空态 / 阶段)走 `catalog/instances.rs`,
|
||||
//! 见 `text.rs` 与 `app/view/instances.rs`。
|
||||
//!
|
||||
//! 这条路径(`pages_demo` harness / 单测)**恒英文**:`pages::demo_model` 走 `Model::new`,
|
||||
//! `lang` 是 `Lang::En`。产品路径不读这份数据,它画的是 presenter 快照。
|
||||
|
||||
/// 演示条目(骨架期没有业务数据;文案是英文基线,i18n 是后续 mission)。
|
||||
/// 演示条目(骨架期没有业务数据;夹具文案,见模块文档)。
|
||||
pub(crate) struct Entry {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) version: &'static str,
|
||||
|
||||
@@ -3,9 +3,25 @@
|
||||
//! 只被 [`crate::pages::demo_model`] 用:产品路径的转换在 `app/view.rs`(presenter 快照 → 模型)。
|
||||
//! 两份数据源喂同一个渲染函数,这是 [`crate::pages::model`] 存在的理由。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use super::super::model::{Detail, Instances, Meta, Row};
|
||||
use super::data::{DEMO, NOTE, SELECTED};
|
||||
|
||||
/// 演示路径的目录:**恒英文**。
|
||||
///
|
||||
/// 为什么不是 `model.lang`:本函数只被 `pages::demo_model` 调用,而那条路(`pages_demo` harness /
|
||||
/// 单测)走 `Model::new`,`lang` 恒为 `Lang::En`(harness 没有换语言的开关)。产品路径不读这里
|
||||
/// ——它走 `app/view.rs` 的快照转换。等 harness 有 `--lang` 时把这一行换成参数即可。
|
||||
fn catalog() -> Catalog {
|
||||
Lang::En.catalog()
|
||||
}
|
||||
|
||||
/// 元信息卡的标签(与 `app/view/instances.rs` 的 `detail_of` 同一批 key,只是这里恒英文)。
|
||||
fn label(key: Key) -> &'static str {
|
||||
catalog().text(key)
|
||||
}
|
||||
|
||||
/// 把 M84 的演示条目 [`DEMO`] 转成视图模型(真页面走 `app/view.rs` 的 `AppState` 转换)。
|
||||
pub(crate) fn demo() -> Instances {
|
||||
let rows = DEMO
|
||||
@@ -24,12 +40,12 @@ pub(crate) fn demo() -> Instances {
|
||||
name: entry.name.to_string(),
|
||||
subtitle: format!("{} · {}", entry.version, entry.loader),
|
||||
meta: vec![
|
||||
meta("VERSION", entry.version),
|
||||
meta("LOADER", entry.loader),
|
||||
meta("MEMORY", entry.memory),
|
||||
meta("JAVA", entry.java),
|
||||
meta("MODS", entry.mods),
|
||||
meta("LAST PLAYED", entry.last_played),
|
||||
meta(label(Key::InstancesMetaVersion), entry.version),
|
||||
meta(label(Key::InstancesMetaLoader), entry.loader),
|
||||
meta(label(Key::InstancesMetaMemory), entry.memory),
|
||||
meta(label(Key::InstancesMetaJava), entry.java),
|
||||
meta(label(Key::InstancesMetaMods), entry.mods),
|
||||
meta(label(Key::InstancesMetaLastPlayed), entry.last_played),
|
||||
],
|
||||
note: Some(NOTE.to_string()),
|
||||
status: None,
|
||||
|
||||
@@ -23,6 +23,7 @@ use super::super::layout::{self, FrameMetrics};
|
||||
use super::super::model::Detail;
|
||||
use super::super::{Action, Message};
|
||||
use super::forced_state;
|
||||
use super::Texts;
|
||||
|
||||
// --------------------------------------------------------------- 详情列
|
||||
|
||||
@@ -31,9 +32,10 @@ pub(super) fn detail_column<'a>(
|
||||
metrics: FrameMetrics,
|
||||
detail: &'a Detail,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
let mut body = column![
|
||||
detail_head(ui, detail, forced),
|
||||
detail_head(ui, detail, forced, texts),
|
||||
meta_grid(ui, metrics, detail),
|
||||
divider::full(ui),
|
||||
]
|
||||
@@ -43,7 +45,7 @@ pub(super) fn detail_column<'a>(
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
|
||||
// 页头 + 元信息网格 + **一条**分隔线以下才是正文与操作区(M84 那两条线是占位时期的排版残留)
|
||||
frame::left_aligned(metrics.column, body.push(action_row(ui, forced)))
|
||||
frame::left_aligned(metrics.column, body.push(action_row(ui, forced, texts)))
|
||||
}
|
||||
|
||||
pub(super) fn narrow_detail<'a>(
|
||||
@@ -51,19 +53,20 @@ pub(super) fn narrow_detail<'a>(
|
||||
metrics: FrameMetrics,
|
||||
detail: &'a Detail,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
// 窄窗:列表退成上一屏,本屏只放详情(官方 compact 的 list-detail 口径)。
|
||||
let mut body = column![
|
||||
row![buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("Back to instances", Message::Navigate(Route::Instances)),
|
||||
Button::new(texts.back(), Message::Navigate(Route::Instances)),
|
||||
forced,
|
||||
),
|
||||
)]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
detail_head(ui, detail, forced),
|
||||
detail_head(ui, detail, forced, texts),
|
||||
meta_grid(ui, metrics, detail),
|
||||
divider::full(ui),
|
||||
]
|
||||
@@ -76,12 +79,11 @@ pub(super) fn narrow_detail<'a>(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
"Below 840 px the list and the detail share one column, so opening an instance replaces \
|
||||
this screen (full-page switch). Widen the window to see both panes again.",
|
||||
texts.narrow_note(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
|
||||
frame::body(metrics, body.push(action_row(ui, forced)))
|
||||
frame::body(metrics, body.push(action_row(ui, forced, texts)))
|
||||
}
|
||||
|
||||
/// 正文段(演示数据才有)与运行状态行(真数据才有)——两者都是可选的,顺序固定。
|
||||
@@ -122,6 +124,7 @@ fn detail_head<'a>(
|
||||
ui: &'a Ui,
|
||||
detail: &'a Detail,
|
||||
forced: Option<Interaction>,
|
||||
texts: Texts,
|
||||
) -> Element<'a, Message> {
|
||||
let titles = column![
|
||||
frame::text_role(
|
||||
@@ -147,23 +150,23 @@ fn detail_head<'a>(
|
||||
buttons::filled(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("Play", Message::Launch(detail.id.clone())),
|
||||
Button::new(texts.play(), Message::Launch(detail.id.clone())),
|
||||
forced,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
buttons::filled(ui, Button::disabled("Play"))
|
||||
buttons::filled(ui, Button::disabled(texts.play()))
|
||||
};
|
||||
let stop = if detail.can_stop {
|
||||
buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("Stop", Message::Stop(detail.id.clone())),
|
||||
Button::new(texts.stop(), Message::Stop(detail.id.clone())),
|
||||
forced,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
buttons::text(ui, Button::disabled("Stop"))
|
||||
buttons::text(ui, Button::disabled(texts.stop()))
|
||||
};
|
||||
|
||||
row![titles, launch, stop]
|
||||
@@ -213,26 +216,26 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Eleme
|
||||
grid.into()
|
||||
}
|
||||
|
||||
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>) -> Element<'a, Message> {
|
||||
fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Element<'a, Message> {
|
||||
row![
|
||||
buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("Duplicate", Message::Action(Action::Duplicate)),
|
||||
Button::new(texts.duplicate(), Message::Action(Action::Duplicate)),
|
||||
forced,
|
||||
)
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("Open folder", Message::Action(Action::OpenFolder)),
|
||||
Button::new(texts.open_folder(), Message::Action(Action::OpenFolder)),
|
||||
forced,
|
||||
)
|
||||
),
|
||||
buttons::text(
|
||||
ui,
|
||||
forced_state(
|
||||
Button::new("Delete", Message::Action(Action::Delete)),
|
||||
Button::new(texts.delete(), Message::Action(Action::Delete)),
|
||||
forced
|
||||
),
|
||||
),
|
||||
@@ -242,12 +245,12 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>) -> Element<'a, Messag
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn detail_empty<'a>(ui: &'a Ui) -> Element<'a, Message> {
|
||||
pub(super) fn detail_empty<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
|
||||
frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
"No instance selected",
|
||||
"Pick an instance on the left to see its version, loader and launch options.",
|
||||
texts.empty_title(),
|
||||
texts.empty_body(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
//! `instances` 的断言:演示数据、窄窗退化,以及**页面摆法**(源码结构断言)。
|
||||
use super::*;
|
||||
|
||||
/// M120 的文案守门(中文无英文词 / 切语言真的变 / 页面里不许写死英文案·白名单)。
|
||||
mod i18n;
|
||||
|
||||
use super::super::layout::source;
|
||||
use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass};
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! 实例页的**文案守门**(M120):中文态不许漏英文、切语言必须真的变、运行状态的数字不许变形。
|
||||
//!
|
||||
//! | 用例 | 抓得住什么 |
|
||||
//! |---|---|
|
||||
//! | [`the_chinese_page_has_no_english_words`] | 目录里某条中文忘了翻(或翻完还留着英文词) |
|
||||
//! | [`every_instances_text_changes_with_the_language`] | 两套语言写成一样(= 切换语言时那一处不动,看着像「已 i18n」其实是死文案) |
|
||||
//! | [`the_running_line_keeps_its_number_and_separator`] | `Running · pid 1234` 的数字 / `pid` / `·` 被翻译走样 |
|
||||
//! | [`every_english_literal_in_the_page_sources_is_whitelisted`] | 页面里写死**任何**英文(旧串或新写的,白名单判定;r1 之前是黑名单,对新写的英文是哑的) |
|
||||
//! | [`every_instances_key_is_referenced_by_the_page`] | 目录里加了 key、页面没人用(死 key,M117 那七条就是这么来的) |
|
||||
//!
|
||||
//! ## 边界(别把这张表读成「视图覆盖」)
|
||||
//!
|
||||
//! iced 0.14 的 `Element` **没有内省接口**(`instances/tests.rs` 里那条「为什么读源码」写的就是它),
|
||||
//! 所以「上屏文本」取样只能从页面取词的那**一个入口** [`Texts`] 拿:页面用它画、测试用同一个
|
||||
//! `Texts` 断言,不是另写一份平行实现。「页面又冒出一条**新**硬编码英文字面量」由
|
||||
//! [`every_english_literal_in_the_page_sources_is_whitelisted`] 兜(白名单);**中文**写死的
|
||||
//! 字面量它看不见,那只能靠 `Texts::of(` 的反向锚与出图人工看。
|
||||
//! 「模型这一层(快照 → 状态行 / 元信息)跟着 `Lang` 变」由 `app/view/tests.rs` 守。
|
||||
|
||||
use unlucky_i18n::catalog::MISSING;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use super::super::Texts;
|
||||
|
||||
/// 中文界面里允许出现的 ASCII 词:**品牌名与技术标识**。
|
||||
///
|
||||
/// 每一条都要说清为什么:
|
||||
///
|
||||
/// - `Java`:运行时品牌名(`JAVA` 那格的值域里也全是 `Temurin 21` 这类品牌串),翻成「爪哇」是错的;
|
||||
/// - `pid`:进程号的技术标识。用户拿它去任务管理器里搜进程,翻成「进程」就丢了这条可操作性。
|
||||
const ALLOWED: &[&str] = &["Java", "pid"];
|
||||
|
||||
/// 页面取词的五个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
const PAGE_SOURCES: [(&str, &str); 5] = [
|
||||
("instances.rs", include_str!("../../instances.rs")),
|
||||
("instances/detail.rs", include_str!("../detail.rs")),
|
||||
("instances/demo.rs", include_str!("../demo.rs")),
|
||||
("instances/text.rs", include_str!("../text.rs")),
|
||||
(
|
||||
"app/view/instances.rs",
|
||||
include_str!("../../../app/view/instances.rs"),
|
||||
),
|
||||
];
|
||||
|
||||
/// 页面五件套里**允许出现的字符串字面量**(凡是带 ASCII 单词的都必须在这张表里)。
|
||||
///
|
||||
/// M120 r1 复审实测出黑名单的哑点:只兜「已知的 29 条旧串」时,往页面里**新写一句英文**
|
||||
/// 照样 612/612 + 12/12 全绿。判据现在反过来——**白名单**:页面源码里每个带 ASCII 单词的
|
||||
/// 字面量都要在这里找到归宿,新写一句英文立刻红。
|
||||
///
|
||||
/// 全部 6 条都写了理由(剩下一批 `"{} · {}"` / `"{} · {:.0}%"` / `"{} {}"` 这类格式化模板
|
||||
/// **连一个词都没有**,所以不需要条目):
|
||||
///
|
||||
/// | 字面量 | 为什么它可以是英文 |
|
||||
/// |---|---|
|
||||
/// | `{date} {time} UTC` | `UTC` 是时区缩写,不是界面文案(本地时区换算属时间格式 mission) |
|
||||
/// | `isolated` / `shared` | core 的隔离档位**稳定 id**(`IsolationMode::as_str`),`isolation()` 只认这两档,其余原样上屏 |
|
||||
/// | `pid` / `reason` | `Catalog::render` 的**占位符名**(与 catalog 里的 `{pid}` / `{reason}` 对应) |
|
||||
/// | `Instances` | `Texts::keys()` 的**族名前缀**(`Key::ALL` 的过滤条件,不是文案) |
|
||||
const ALLOWED_LITERALS: [&str; 6] = [
|
||||
"{date} {time} UTC",
|
||||
"isolated",
|
||||
"shared",
|
||||
"pid",
|
||||
"reason",
|
||||
"Instances",
|
||||
];
|
||||
|
||||
/// 抽出源码里的字符串字面量(够用的实现:本仓这五个文件没有 `\"` 转义、没有多行字符串)。
|
||||
///
|
||||
/// 按 `"` 切开取**奇数段**即可:`a"b"c"d"e` → 段 1 = `b`、段 3 = `d`。
|
||||
fn string_literals(source: &str) -> Vec<&str> {
|
||||
source.split('"').skip(1).step_by(2).collect()
|
||||
}
|
||||
|
||||
/// 把 `{占位符}` 抹掉再切 ASCII 词:占位符是**运行时数据**(pid / 原因 / 时间),不是文案。
|
||||
fn ascii_words(text: &str) -> Vec<String> {
|
||||
let mut stripped = String::new();
|
||||
let mut depth = 0usize;
|
||||
for ch in text.chars() {
|
||||
match ch {
|
||||
'{' => depth += 1,
|
||||
'}' if depth > 0 => depth -= 1,
|
||||
_ if depth > 0 => {}
|
||||
_ => stripped.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
for ch in stripped.chars() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
current.push(ch);
|
||||
} else if !current.is_empty() {
|
||||
words.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
words.push(current);
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// 有没有中日韩统一表意文字(「这条文案真的翻成中文了吗」的最直接判据)。
|
||||
fn has_cjk(text: &str) -> bool {
|
||||
text.chars().any(|c| {
|
||||
matches!(c, '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{3000}'..='\u{303F}')
|
||||
})
|
||||
}
|
||||
|
||||
/// **中文界面无英文词**:实例页这一族的每一条中文译文,除了 [`ALLOWED`] 之外不许出现 ASCII 单词。
|
||||
///
|
||||
/// 抓得住:漏翻(中英写成一样)、翻了一半(`Present on disk` → `已在磁盘上 Present`)、
|
||||
/// 新加 key 时中文那栏顺手抄了英文。
|
||||
/// 抓不住:译文本身读着别扭;也抓不住「页面另写了一条字面量」(见模块文档的边界一节)。
|
||||
///
|
||||
/// **变异实测**(M120 交付时跑过):把 `InstancesPresentOnDisk` 的中文改回 `Present on disk`,
|
||||
/// 这条立刻红并指出 key 名。
|
||||
#[test]
|
||||
fn the_chinese_page_has_no_english_words() {
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
let mut offenders = Vec::new();
|
||||
let mut checked = 0;
|
||||
|
||||
for key in Texts::keys() {
|
||||
let text = zh.text(key);
|
||||
assert_ne!(text, MISSING, "{} 没有中文译文", key.name());
|
||||
checked += 1;
|
||||
for word in ascii_words(text) {
|
||||
if !ALLOWED.contains(&word.as_str()) {
|
||||
offenders.push(format!("{} · 出现 {word:?} ← 「{text}」", key.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"中文界面上这些英文词会漏出来(品牌名 / 技术标识请加进 ALLOWED 并写明理由):\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
assert!(
|
||||
checked >= 30,
|
||||
"实例页的 key 不该被成批删掉(只查到 {checked} 条)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **切换语言,文本真的变**:这一族的每一条中英译文都必须不同。
|
||||
///
|
||||
/// 抓得住:翻完没改(两栏抄同一句)——那种 key 在切语言时**一个字都不动**,界面上看起来
|
||||
/// 「已经国际化」,其实那一处永远是英文。今夜反复出现的「测试绿着但什么都没守住」就是这一类。
|
||||
/// 抓不住:某条译文翻错了词。
|
||||
#[test]
|
||||
fn every_instances_text_changes_with_the_language() {
|
||||
let en = Lang::En.catalog();
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
|
||||
for key in Texts::keys() {
|
||||
let (a, b) = (en.text(key), zh.text(key));
|
||||
assert_ne!(a, MISSING, "{} 没有英文译文", key.name());
|
||||
assert_ne!(b, MISSING, "{} 没有中文译文", key.name());
|
||||
assert_ne!(
|
||||
a,
|
||||
b,
|
||||
"{} 的两套语言写成了一样({a:?})——切语言时这一处不会变",
|
||||
key.name()
|
||||
);
|
||||
// 中文那栏必须真的有汉字——**整句只有品牌名时例外**(`InstancesMetaJava` = "Java":
|
||||
// 那一格的值域是 `Temurin 21` 这种品牌串,翻成「爪哇」是错的)。
|
||||
let brand_only = {
|
||||
let words = ascii_words(b);
|
||||
!words.is_empty() && words.iter().all(|word| ALLOWED.contains(&word.as_str()))
|
||||
};
|
||||
assert!(
|
||||
has_cjk(b) || brand_only,
|
||||
"{} 的中文译文里一个汉字都没有:{b:?}",
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **数字与分隔符不随语言变形**:`Running · pid 1234` 的两套语言只换前半句。
|
||||
///
|
||||
/// 抓得住:把 `pid` 翻成「进程」、把 `·` 换成 `-`、把 `{pid}` 写成固定文字、少一个空格。
|
||||
/// 抓不住:真实 pid 的取值(那是 core 的事,出图那台机器上也没真跑过)。
|
||||
#[test]
|
||||
fn the_running_line_keeps_its_number_and_separator() {
|
||||
let en = Catalog::of(Lang::En).render(Key::InstancesRunning, &[("pid", "1234")]);
|
||||
let zh = Catalog::of(Lang::ZhCn).render(Key::InstancesRunning, &[("pid", "1234")]);
|
||||
|
||||
assert_eq!(en, "Running · pid 1234");
|
||||
assert_eq!(zh, "运行中 · pid 1234");
|
||||
assert_ne!(en, zh, "切换语言时这一行要变(只换标签本体)");
|
||||
for text in [en, zh] {
|
||||
assert!(text.contains("1234"), "数字丢了:{text}");
|
||||
assert!(text.contains(" · pid "), "分隔符或 pid 变形了:{text}");
|
||||
}
|
||||
|
||||
// 失败那一行同理:core 给的原因原文原样保留,只翻前半句
|
||||
let failed_en = Catalog::of(Lang::En).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
||||
let failed_zh = Catalog::of(Lang::ZhCn).render(Key::InstancesFailed, &[("reason", "exit 1")]);
|
||||
assert_eq!(failed_en, "Launch failed · exit 1");
|
||||
assert_eq!(failed_zh, "启动失败 · exit 1");
|
||||
}
|
||||
|
||||
/// **页面里不许再写死界面文案**(白名单):带 ASCII 单词的字面量必须在 [`ALLOWED_LITERALS`] 里。
|
||||
///
|
||||
/// 抓得住:①M120 之前那 29 条旧串被抄回页面(它们都带英文词 ⇒ 不在白名单 ⇒ 红);
|
||||
/// ②**任何新写的英文字面量**(r1 复审抓到的哑点:黑名单版本把 `"Ready"` 写进页面是全绿的)。
|
||||
/// 抓不住:中文写死的字面量(`"实例"` 没有 ASCII 词)——那要靠 `Texts::of(` 的反向锚与出图;
|
||||
/// 也抓不住夹具数据(`pages/instances/data.rs` 故意不在扫描范围:那是**用户数据**的样子,
|
||||
/// 不是界面文案,见该文件的模块文档)。
|
||||
///
|
||||
/// **变异实测(M120 r1 交付时跑过三条)**:写回 `"ALL INSTANCES"` ⇒ 红;写进一条全新的
|
||||
/// `"Ready"` ⇒ 红;把 `Texts::of(` 从实例页删掉 ⇒ 反向锚红。
|
||||
#[test]
|
||||
fn every_english_literal_in_the_page_sources_is_whitelisted() {
|
||||
let mut checked = 0;
|
||||
for (name, source) in PAGE_SOURCES {
|
||||
for literal in string_literals(source) {
|
||||
if ascii_words(literal).is_empty() {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
assert!(
|
||||
ALLOWED_LITERALS.contains(&literal),
|
||||
"{name} 里的字面量 {literal:?} 带 ASCII 单词但不在白名单里:界面文案要走 Texts / 目录;确属专有名词的话加进 ALLOWED_LITERALS 并写明理由"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 反向锚①:扫描器真的扫到了东西(否则上面那个循环是空转)
|
||||
assert!(
|
||||
checked >= 6,
|
||||
"只扫到 {checked} 条带英文的字面量,扫描器可能失效了"
|
||||
);
|
||||
|
||||
// 反向锚②:这些来源**必须**真的在用 Texts / 目录
|
||||
assert!(
|
||||
PAGE_SOURCES[0].1.contains("Texts::of("),
|
||||
"实例页没有从 Texts 取词"
|
||||
);
|
||||
assert!(
|
||||
PAGE_SOURCES[0].1.contains("Header::new(texts.title())"),
|
||||
"页头标题必须是词表里的那条(`Header::new` 里写死字符串 = 中文界面上顶着英文标题)"
|
||||
);
|
||||
assert!(
|
||||
PAGE_SOURCES[3].1.contains("Key::Instances"),
|
||||
"text.rs 没有引用本族的 key"
|
||||
);
|
||||
assert!(
|
||||
PAGE_SOURCES[4].1.contains("Key::InstancesStage"),
|
||||
"阶段标签没有走目录"
|
||||
);
|
||||
}
|
||||
|
||||
/// **死 key 守门**:目录里每一条 `Instances*` 都必须被页面(或产品快照转换)真的引用。
|
||||
///
|
||||
/// M117 加的那七条就是这么沉的:目录里写着,页面上一个都没用。扫描是字面出现的
|
||||
/// `Key::<名字>`,所以用宏拼 key 名能骗过它——这个局限写在这里,不假装它更强。
|
||||
#[test]
|
||||
fn every_instances_key_is_referenced_by_the_page() {
|
||||
for key in Texts::keys() {
|
||||
let needle = format!("Key::{}", key.name());
|
||||
assert!(
|
||||
PAGE_SOURCES
|
||||
.iter()
|
||||
.any(|(_, source)| source.contains(&needle)),
|
||||
"{needle} 在目录里定义了,但实例页没有任何地方用它(死 key,删掉或接上)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 实例页的**文案入口**:页面上一处可见英文字面量都不留,全部从目录取。
|
||||
//!
|
||||
//! ## 为什么要有这一层(而不是在控件树里直接 `catalog.text(..)`)
|
||||
//!
|
||||
//! 页面要的文案有 20 多条,直接写的话每个调用点都是「key 名 + 这一处画什么」的重复劳动,
|
||||
//! 而且**守门测试没法取样**:iced 0.14 的 `Element` 没有内省接口(`instances/tests.rs` 里
|
||||
//! 「读源码」那条注释解释过同一件事),所以「上屏文本」只能从**页面取词的那一个入口**拿。
|
||||
//! 于是所有取词收进 [`Texts`],页面用 `texts.xxx()` 画,测试用同一个 `Texts` 断言——
|
||||
//! 一处定义、两处消费,不存在「测试抄了一份平行实现」。
|
||||
//!
|
||||
//! ## 它抓得住什么 / 抓不住什么
|
||||
//!
|
||||
//! - **抓得住**:目录里某条中文忘了翻(「无 ASCII 英文单词」)、某条中英写成一样(「切换语言文本真的变」)、
|
||||
//! `Running · pid {pid}` 的数字与分隔符被翻译走样、页面里写死**英文**字面量
|
||||
//! (`every_english_literal_in_the_page_sources_is_whitelisted`,白名单判定);
|
||||
//! - **抓不住**:页面里写死的**中文**字面量(没有 ASCII 词,扫不到)、`PAGE_SOURCES` 五个文件
|
||||
//! 之外的东西(如 `data.rs` 夹具数据)、译文翻得顺不顺——这些只能靠出图人工看
|
||||
//! (`DevDocs/artifacts/m120/`)。
|
||||
//!
|
||||
//! ## 口径
|
||||
//!
|
||||
//! 品牌名不翻译(`Java` / `Fabric` / `Forge` 原样);`pid` 是进程号的技术标识,两套语言都写
|
||||
//! `pid`(改成「进程」会丢掉「拿它去任务管理器里搜」这条可操作性)。
|
||||
//!
|
||||
//! **启动阶段(下载中 / 校验中 / …)不在这张表里**:它在 `app/view/instances.rs` 拼进
|
||||
//! `Row.status`(「阶段 · 百分比」是一整句),与元信息卡同一层——页面只画模型给的字符串。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
/// 实例页 + 详情列的文案(一个 `Catalog` 的薄包装,`Copy`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Texts(Catalog);
|
||||
|
||||
impl Texts {
|
||||
/// 从目录取一套(调用方给 `model.lang.catalog()`)。
|
||||
pub(super) fn of(catalog: Catalog) -> Self {
|
||||
Self(catalog)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 页头
|
||||
pub(super) fn title(self) -> &'static str {
|
||||
self.text(Key::InstancesTitle)
|
||||
}
|
||||
pub(super) fn subtitle(self) -> &'static str {
|
||||
self.text(Key::InstancesSubtitle)
|
||||
}
|
||||
pub(super) fn new_instance(self) -> &'static str {
|
||||
self.text(Key::InstancesNew)
|
||||
}
|
||||
pub(super) fn import(self) -> &'static str {
|
||||
self.text(Key::InstancesImport)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 列表列
|
||||
pub(super) fn all_title(self) -> &'static str {
|
||||
self.text(Key::InstancesAllTitle)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 详情列
|
||||
pub(super) fn back(self) -> &'static str {
|
||||
self.text(Key::InstancesBack)
|
||||
}
|
||||
pub(super) fn play(self) -> &'static str {
|
||||
self.text(Key::InstancesPlay)
|
||||
}
|
||||
pub(super) fn stop(self) -> &'static str {
|
||||
self.text(Key::InstancesStop)
|
||||
}
|
||||
pub(super) fn duplicate(self) -> &'static str {
|
||||
self.text(Key::InstancesDuplicate)
|
||||
}
|
||||
pub(super) fn open_folder(self) -> &'static str {
|
||||
self.text(Key::InstancesOpenFolder)
|
||||
}
|
||||
pub(super) fn delete(self) -> &'static str {
|
||||
self.text(Key::InstancesDelete)
|
||||
}
|
||||
pub(super) fn empty_title(self) -> &'static str {
|
||||
self.text(Key::InstancesEmptyTitle)
|
||||
}
|
||||
pub(super) fn empty_body(self) -> &'static str {
|
||||
self.text(Key::InstancesEmptyBody)
|
||||
}
|
||||
pub(super) fn narrow_note(self) -> &'static str {
|
||||
self.text(Key::InstancesNarrowNote)
|
||||
}
|
||||
|
||||
fn text(self, key: Key) -> &'static str {
|
||||
self.0.text(key)
|
||||
}
|
||||
|
||||
/// 这一页的全部 key(守门测试的取样口:**从 `Key::ALL` 过滤出来**,不是手抄一份清单)。
|
||||
///
|
||||
/// 域判据是前缀 `Instances`——本族的 key 名是契约(`catalog/instances.rs` 里一条一条写着),
|
||||
/// 新加的 key 只要还在这个族里就自动进守门范围,不需要有人记得来登记。
|
||||
#[cfg(test)]
|
||||
pub(super) fn keys() -> impl Iterator<Item = Key> {
|
||||
Key::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| key.name().starts_with("Instances"))
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,20 @@ mod settings;
|
||||
///
|
||||
/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
|
||||
/// 其余一字未动」,不再是「整表一字未动」。
|
||||
///
|
||||
/// ## M120 动了实例页那一族(1 条改译文 + 1 条删除 + 28 条新增)
|
||||
///
|
||||
/// 实例页的文案以前是英文基线字面量,M120 全搬进 `catalog/instances.rs`。那一族在装配链
|
||||
/// **第 2 位**(紧跟 nav),所以这批行是**插在中间**的,不是文件末尾追加:
|
||||
///
|
||||
/// | 动作 | 条目 |
|
||||
/// |---|---|
|
||||
/// | 改译文 | [`Key::InstancesEmptyBody`](换成详情列真正画的那句) |
|
||||
/// | 删除 | `InstancesLastPlayed`(M117 加的整句,从未接线;页面是「标签 + 值」的元信息卡) |
|
||||
/// | 新增 | `InstancesTitle` … `InstancesStageWindow`(页头 / 动作 / 空态 / 元信息标签与值 / 隔离档位 / 运行状态 / 阶段,共 28 条) |
|
||||
///
|
||||
/// 位置跟着族走这个事实本身就是判据:金表是 `Key::ALL` 的逐 index 镜像,
|
||||
/// 「为把新 key 排到文件末尾而去重排别人的族」会让这里整片红。
|
||||
#[cfg(test)]
|
||||
mod golden;
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。
|
||||
//!
|
||||
//! 「为什么有这么一张表、它抓得住什么」写在 `catalog.rs` 中 `mod golden;` 的文档里——
|
||||
//! M119 r2 把它搬到那儿,是为了给这张表腾出追加新 key 的行数(400 行硬线),一个字的说明都没删。
|
||||
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。「为什么有这张表、它抓得住
|
||||
//! 什么」全在 `catalog.rs` 的 `mod golden;` 文档里——本文件只放数据,是因为每加一条 key 就多一行。
|
||||
|
||||
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
|
||||
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
@@ -14,9 +12,36 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("InstancesImport", "Import", "导入"),
|
||||
("InstancesBack", "Back to instances", "返回实例列表"),
|
||||
("InstancesPlay", "Play", "启动"),
|
||||
("InstancesLastPlayed", "Last played {when}", "上次游玩 {when}"),
|
||||
("InstancesEmptyTitle", "No instance selected", "未选择实例"),
|
||||
("InstancesEmptyBody", "Pick an instance to see its details.", "选择一个实例查看详情。"),
|
||||
("InstancesEmptyBody", "Pick an instance on the left to see its version, loader and launch options.", "在左侧选择一个实例,查看它的版本、加载器与启动选项。"),
|
||||
("InstancesTitle", "Instances", "实例"),
|
||||
("InstancesSubtitle", "Local versions and their mod loaders", "本地版本与它们的模组加载器"),
|
||||
("InstancesAllTitle", "ALL INSTANCES", "全部实例"),
|
||||
("InstancesStop", "Stop", "停止"),
|
||||
("InstancesDuplicate", "Duplicate", "复制"),
|
||||
("InstancesOpenFolder", "Open folder", "打开文件夹"),
|
||||
("InstancesDelete", "Delete", "删除"),
|
||||
("InstancesNarrowNote", "Below 840 px the list and the detail share one column, so opening an instance replaces this screen (full-page switch). Widen the window to see both panes again.", "窗口窄于 840 像素时,列表与详情共用一列:打开一个实例会整页替换这一屏(整页切换)。把窗口拉宽就能重新同时看见两栏。"),
|
||||
("InstancesMetaVersion", "VERSION", "版本"),
|
||||
("InstancesMetaLoader", "LOADER", "加载器"),
|
||||
("InstancesMetaMemory", "MEMORY", "内存"),
|
||||
("InstancesMetaJava", "JAVA", "Java"),
|
||||
("InstancesMetaMods", "MODS", "模组"),
|
||||
("InstancesMetaIsolation", "ISOLATION", "隔离方式"),
|
||||
("InstancesMetaVersionId", "VERSION ID", "版本标识"),
|
||||
("InstancesMetaFiles", "FILES", "文件"),
|
||||
("InstancesMetaLastPlayed", "LAST PLAYED", "上次游玩"),
|
||||
("InstancesPresentOnDisk", "Present on disk", "已在磁盘上"),
|
||||
("InstancesMissing", "Missing", "缺失"),
|
||||
("InstancesNeverPlayed", "Never", "从未游玩"),
|
||||
("InstancesIsolationIsolated", "Isolated", "独立目录"),
|
||||
("InstancesIsolationShared", "Shared", "共用目录"),
|
||||
("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"),
|
||||
("InstancesFailed", "Launch failed · {reason}", "启动失败 · {reason}"),
|
||||
("InstancesStageDownload", "Downloading", "下载中"),
|
||||
("InstancesStageVerify", "Verifying", "校验中"),
|
||||
("InstancesStageLaunch", "Starting", "启动中"),
|
||||
("InstancesStageWindow", "Waiting for window", "等待游戏窗口"),
|
||||
("LoaderVanilla", "Vanilla", "原版"),
|
||||
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
|
||||
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
//! 实例页文案:页头动作、返回、上次游玩时间、空态。
|
||||
//! 实例页文案:页头、列表列头、详情动作、元信息卡的标签与值、运行状态与启动阶段。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
//!
|
||||
//! ## 这一族在装配链的**第 2 位**(M120 钉住)
|
||||
//!
|
||||
//! `instances_entries!` 紧跟 `nav_entries!`,所以本族的 key 落在 `Key::ALL` 的前段、
|
||||
//! `LoaderVanilla` 之前。M120 的新 key 因此**追加在本域末尾**(= `InstancesEmptyBody` 之后),
|
||||
//! 既没插到别的族中间,也没为了「排在文件末尾」去重排别人的族:
|
||||
//! 金表([`crate::catalog::golden`])是 `Key::ALL` 的逐 index 镜像,位置跟着族走。
|
||||
//!
|
||||
//! ## M120 之前这七条全是**死 key**
|
||||
//!
|
||||
//! M117 加 `InstancesNew` / `InstancesImport` / `InstancesBack` / `InstancesPlay` /
|
||||
//! `InstancesLastPlayed` / `InstancesEmptyTitle` / `InstancesEmptyBody` 时,实例页并没有接它们
|
||||
//! ——页面上还是英文基线字面量(`"New instance"` / `"ALL INSTANCES"` / `"Present on disk"`…)。
|
||||
//! M120 一次接完,同时:
|
||||
//!
|
||||
//! - `InstancesEmptyBody` 的译文换成详情列真正画的那句(原来那句是「看详情」的泛说);
|
||||
//! - **`InstancesLastPlayed` 删掉**:它的形状是「上一次游玩 {when}」这种整句,而页面上是
|
||||
//! 元信息卡(标签 `LAST PLAYED` + 值 `2026-10-29 19:22 UTC`,或值 `Never`)。留着它只会
|
||||
//! 多出一条没人用的 key,所以拆成 [`Key::InstancesMetaLastPlayed`](标签)与
|
||||
//! [`Key::InstancesNeverPlayed`](值)。
|
||||
//!
|
||||
//! ## 两条口径(复审会盯)
|
||||
//!
|
||||
//! 1. **品牌与技术标识不翻译**:`Java`(元信息标签的值域)与 `pid`(进程号)原样保留两套语言;
|
||||
//! 2. **数字与分隔符不随语言变形**:[`Key::InstancesRunning`] 的中英都写成
|
||||
//! `… · pid {pid}`,只有前半句换了;`{pid}` 之外一个字都不许动(`pages/instances/tests/i18n.rs`
|
||||
//! 用「中英同位置取占位符 + 数字原样」的断言钉这条)。
|
||||
|
||||
macro_rules! instances_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 实例页
|
||||
// ── 实例页(M117 的七条:其中 `InstancesLastPlayed` 见模块文档,M120 删除)
|
||||
InstancesNew => ("New instance", "新建实例"),
|
||||
InstancesImport => ("Import", "导入"),
|
||||
InstancesBack => ("Back to instances", "返回实例列表"),
|
||||
InstancesPlay => ("Play", "启动"),
|
||||
InstancesLastPlayed => ("Last played {when}", "上次游玩 {when}"),
|
||||
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
|
||||
InstancesEmptyBody => ("Pick an instance to see its details.", "选择一个实例查看详情。"),
|
||||
InstancesEmptyBody => ("Pick an instance on the left to see its version, loader and launch options.", "在左侧选择一个实例,查看它的版本、加载器与启动选项。"),
|
||||
// ── 页头与列表列头(M120)
|
||||
InstancesTitle => ("Instances", "实例"),
|
||||
InstancesSubtitle => ("Local versions and their mod loaders", "本地版本与它们的模组加载器"),
|
||||
InstancesAllTitle => ("ALL INSTANCES", "全部实例"),
|
||||
// ── 详情动作(M120)
|
||||
InstancesStop => ("Stop", "停止"),
|
||||
InstancesDuplicate => ("Duplicate", "复制"),
|
||||
InstancesOpenFolder => ("Open folder", "打开文件夹"),
|
||||
InstancesDelete => ("Delete", "删除"),
|
||||
InstancesNarrowNote => (
|
||||
"Below 840 px the list and the detail share one column, so opening an instance replaces this screen (full-page switch). Widen the window to see both panes again.",
|
||||
"窗口窄于 840 像素时,列表与详情共用一列:打开一个实例会整页替换这一屏(整页切换)。把窗口拉宽就能重新同时看见两栏。"
|
||||
),
|
||||
// ── 元信息卡的标签(M120;`Java` 是品牌名,两套语言都写 Java)
|
||||
InstancesMetaVersion => ("VERSION", "版本"),
|
||||
InstancesMetaLoader => ("LOADER", "加载器"),
|
||||
InstancesMetaMemory => ("MEMORY", "内存"),
|
||||
InstancesMetaJava => ("JAVA", "Java"),
|
||||
InstancesMetaMods => ("MODS", "模组"),
|
||||
InstancesMetaIsolation => ("ISOLATION", "隔离方式"),
|
||||
InstancesMetaVersionId => ("VERSION ID", "版本标识"),
|
||||
InstancesMetaFiles => ("FILES", "文件"),
|
||||
InstancesMetaLastPlayed => ("LAST PLAYED", "上次游玩"),
|
||||
// ── 元信息卡的值(M120)
|
||||
InstancesPresentOnDisk => ("Present on disk", "已在磁盘上"),
|
||||
InstancesMissing => ("Missing", "缺失"),
|
||||
InstancesNeverPlayed => ("Never", "从未游玩"),
|
||||
// 隔离档位的两个稳定 id(`core::game::IsolationMode::as_str`)→ 人话。
|
||||
// 白名单只覆盖真实存在的这两档;`inherit` / `custom` / 未知值原样上屏(不猜)。
|
||||
InstancesIsolationIsolated => ("Isolated", "独立目录"),
|
||||
InstancesIsolationShared => ("Shared", "共用目录"),
|
||||
// ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形)
|
||||
InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"),
|
||||
InstancesFailed => ("Launch failed · {reason}", "启动失败 · {reason}"),
|
||||
InstancesStageDownload => ("Downloading", "下载中"),
|
||||
InstancesStageVerify => ("Verifying", "校验中"),
|
||||
InstancesStageLaunch => ("Starting", "启动中"),
|
||||
InstancesStageWindow => ("Waiting for window", "等待游戏窗口"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ const PARTIAL_ZH: &[(Key, &str)] = &[
|
||||
(Key::InstancesImport, "导入"),
|
||||
(Key::InstancesBack, "返回实例列表"),
|
||||
(Key::InstancesPlay, "启动"),
|
||||
(Key::InstancesLastPlayed, "上次游玩 {when}"),
|
||||
(Key::InstancesEmptyTitle, "未选择实例"),
|
||||
(Key::InstancesEmptyBody, "选择一个实例查看详情。"),
|
||||
(
|
||||
Key::InstancesEmptyBody,
|
||||
"在左侧选择一个实例,查看它的版本、加载器与启动选项。",
|
||||
),
|
||||
(Key::LoaderVanilla, "原版"),
|
||||
(Key::LaunchOutcomeNormal, "正常结束"),
|
||||
(Key::LaunchOutcomeCrash, "游戏崩溃"),
|
||||
|
||||
@@ -13,11 +13,13 @@ use crate::dto::{
|
||||
AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId,
|
||||
InstanceRuntime, LaunchStage, Progress,
|
||||
};
|
||||
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
|
||||
use crate::mock_data::{mock_log_lines, mock_log_sessions, mock_versions};
|
||||
use crate::toast::{Toast, ToastKind};
|
||||
|
||||
#[path = "mock/accounts.rs"]
|
||||
mod accounts_screen;
|
||||
#[path = "mock/instances.rs"]
|
||||
mod instances_screen;
|
||||
|
||||
/// 启动脚本:按时间推进 download → verify → launch → running。
|
||||
#[derive(Debug)]
|
||||
@@ -55,19 +57,19 @@ impl MockBackend {
|
||||
}
|
||||
|
||||
fn seed(&mut self) {
|
||||
self.events.push_back(AppEvent::Core(
|
||||
CoreEvent::InstancesChanged(mock_instances()),
|
||||
));
|
||||
self.events
|
||||
.push_back(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||
mock_versions(),
|
||||
)));
|
||||
self.events
|
||||
.push_back(AppEvent::Core(CoreEvent::LogSessionsChanged(
|
||||
mock_log_sessions(),
|
||||
)));
|
||||
self.events
|
||||
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
|
||||
// 实例的截图矩阵(`ULL_INSTANCE_STATE=empty|demo|running`,见 `mock/instances.rs`)。
|
||||
// 缺省与 `mock_data::mock_instances()` 逐字段相同——这里的开关只给出图用。
|
||||
self.emit(AppEvent::Core(CoreEvent::InstancesChanged(
|
||||
instances_screen::instances(&instances_screen::scenario()),
|
||||
)));
|
||||
// 三条都走 `emit`(同一个队尾),与上面那条实例事件的形状一致
|
||||
self.emit(AppEvent::Core(CoreEvent::VersionListChanged(
|
||||
mock_versions(),
|
||||
)));
|
||||
self.emit(AppEvent::Core(CoreEvent::LogSessionsChanged(
|
||||
mock_log_sessions(),
|
||||
)));
|
||||
self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
|
||||
self.seed_accounts();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 截图/演示用的**实例场景**(只在 mock 后端里被调用)。
|
||||
//!
|
||||
//! D30 要求实例页出状态矩阵:空态 / 有实例 / 运行中(带 pid)/ 启动中(带百分比)。
|
||||
//! 截图通道强制 mock 后端(`AppOptions::mock`),所以这四态的**数据**必须由 mock 造:
|
||||
//! 走 `ULL_INSTANCE_STATE` 选场景,与 `ULL_ACCOUNT_STATE`(M114)/ `ULL_CONTENT_STATE`(M116)
|
||||
//! 同一套取证开关。
|
||||
//!
|
||||
//! ## 三个约束(塔台 M120 批的)
|
||||
//!
|
||||
//! 1. **不设变量 = 今天那 4 条**(Idle×3 + Failed×1)——[`crate::mock_data::mock_instances`]
|
||||
//! 原样返回,产品/单测的行为一字未变;
|
||||
//! 2. **这个开关只影响 mock 通道**:真 host 的实例来自 `core::game` 的快照,读不到这里;
|
||||
//! 3. `running` 的 pid 是**写死的 mock 值**(`1234`),不是真进程 pid——它证明的是
|
||||
//! 「`运行中 · pid {pid}` 这条文案的数字与分隔符不随语言变形」,不是「我能读到真 pid」。
|
||||
//!
|
||||
//! ```text
|
||||
//! ULL_INSTANCE_STATE=empty ULL_START_ROUTE=instances ULL_LANG=zh_cn \
|
||||
//! unlucky-launcher-iced --screenshot=instances-empty-zh.png --software-renderer
|
||||
//! ```
|
||||
|
||||
use crate::dto::{InstanceRuntime, InstanceSummary, LaunchStage, Progress};
|
||||
use crate::mock_data::mock_instances;
|
||||
|
||||
/// 挑演示场景的环境变量。
|
||||
pub(crate) const STATE_ENV: &str = "ULL_INSTANCE_STATE";
|
||||
|
||||
/// 场景名(未知值按 `demo` 处理,与 `ULL_ACCOUNT_STATE` 同一条兜底口径)。
|
||||
pub(super) fn scenario() -> String {
|
||||
std::env::var(STATE_ENV)
|
||||
.unwrap_or_else(|_| "demo".to_string())
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// 场景对应的实例列表。
|
||||
///
|
||||
/// | 场景 | 列表 | 页面看起来 |
|
||||
/// |---|---|---|
|
||||
/// | `empty` | 空表 | 空列表 + 「未选择实例」空态(`detail: None`) |
|
||||
/// | `demo`(缺省 / 未知值) | 4 条 Idle×3 + Failed×1 | 今天的样子(M90 起的基线) |
|
||||
/// | `running` | 4 条,其中 1 条 `Running{pid}`、1 条 `Busy{42%}` | 列表 overline 与详情状态行都带运行态 |
|
||||
pub(super) fn instances(scenario: &str) -> Vec<InstanceSummary> {
|
||||
match scenario {
|
||||
"empty" => Vec::new(),
|
||||
"running" => running(),
|
||||
_ => mock_instances(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行态矩阵:同一批实例换 runtime(名字/版本/加载器都还是 `mock_data` 那一份)。
|
||||
///
|
||||
/// 三种态各留一条,是为了**一屏看完**状态文案的三种形状:正在跑(`pid`)、启动中(百分比)、
|
||||
/// 启动失败(原因)。少一条就得再出一张图,而图多一张就多一处可以撒谎的地方。
|
||||
fn running() -> Vec<InstanceSummary> {
|
||||
let mut list = mock_instances();
|
||||
for instance in &mut list {
|
||||
match instance.id.as_str() {
|
||||
"survival" => instance.runtime = InstanceRuntime::Running { pid: 1234 },
|
||||
"vanilla-latest" => {
|
||||
instance.runtime = InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Download,
|
||||
progress: Progress {
|
||||
done: 630,
|
||||
total: 1_500,
|
||||
},
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **默认行为不变**(塔台三条约束的第 1 条):不设变量就是 `mock_data` 那一份。
|
||||
///
|
||||
/// 抓得住:有人把 `demo` 分支改成了「顺手多造几条」——那会让所有既有截图与断言漂。
|
||||
#[test]
|
||||
fn the_demo_scenario_is_exactly_the_old_mock_list() {
|
||||
for unknown in ["demo", "", "bogus"] {
|
||||
let got = instances(unknown);
|
||||
let want = mock_instances();
|
||||
assert_eq!(got.len(), want.len(), "{unknown:?}");
|
||||
assert_eq!(got, want, "{unknown:?} 必须与 mock_data 逐字段相同");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_empty_scenario_has_no_instances() {
|
||||
assert!(instances("empty").is_empty());
|
||||
}
|
||||
|
||||
/// 运行态矩阵:`Running` / `Busy` 各一条,且**名字与版本没被动手脚**(它们不是场景的一部分)。
|
||||
#[test]
|
||||
fn the_running_scenario_covers_running_and_busy() {
|
||||
let list = instances("running");
|
||||
assert_eq!(list.len(), mock_instances().len());
|
||||
assert!(
|
||||
list.iter().any(
|
||||
|item| matches!(item.runtime, InstanceRuntime::Running { pid } if pid == 1234)
|
||||
),
|
||||
"要有一条带固定 pid 的运行中实例"
|
||||
);
|
||||
assert!(list.iter().any(|item| matches!(
|
||||
item.runtime,
|
||||
InstanceRuntime::Busy {
|
||||
stage: LaunchStage::Download,
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(list[0].name, mock_instances()[0].name);
|
||||
}
|
||||
|
||||
/// 环境变量名是**契约**(出图脚本与报告都写它,改名等于让文档说谎)。
|
||||
#[test]
|
||||
fn the_scenario_variable_is_named() {
|
||||
assert_eq!(STATE_ENV, "ULL_INSTANCE_STATE");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user