fix(presenter): 自动发现实例按盘上痕迹选运行目录 + 空态用例补上分支/传参(M273 r1)
r1 复审的两条 P2。
## P2-1:外部实例不再一律 isolated
`versions/<id>/` 里出现 `mods` / `saves` / `config` / `options.txt` 任一 ⇒ `isolated`
(gameDir = `versions/<id>`);否则 ⇒ `shared`(gameDir = 游戏根 `.minecraft`)。
判据只 `exists()`,只读,不改盘;写盘仍然只在用户第一次改设置时(`ensure_profile`)。
- 判据收在 `host/game/discovered.rs::base_mode()`,签名 `(env, id, version_id)`:
**有档案的实例返回全局 `instances.mode`**(D20 原口径,一字不变),自动发现的按痕迹判。
- 派生运行目录的五处(启动 / mods / saves / addon install / addon pack)从传
`global_mode` 改成传 `base_mode` ⇒ 「读到的存档/模组在哪」与磁贴上的 ISOLATION 同一份判据。
- `isolation_of(env, id, version_id)` 多收一个版本 id:实例文件优先,缺省 `base_mode`。
于是懒创建档案(只写 launch 段、isolation 仍为空)之后,磁贴不会从「共用目录」翻回「独立目录」。
- 磁贴/详情:自动发现的实例多一句「运行目录由盘上已有的文件决定」(i18n 中英);
按启动时再弹一条说清**这一次**用哪一边(`pages::instances::discovered_run_toast`,
同文案在屏不重复叠加)。
- `dto::DISCOVERED_ID_PREFIX` / `is_discovered_instance` 成为「自动发现」的公开判据
(不给 `InstanceSummary` 加字段:那要改 24 处结构体字面量,跨 agent / cli)。
## P2-2:空态用例的覆盖面
r1 复审实测:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`(0 实例时反而没按钮),
当时三条用例全绿。现在:
- `detail_empty` 拆成 `empty_not_selected` / `empty_no_instances` 两个函数,分支各自可断言;
- `the_cta_lives_only_in_the_no_instances_branch`:CTA 只在「一个都没有」那一支、
「没选中」那一支必须是 `Vec::new()`,且分派必须写成 `if has_instances {`(取反即红);
- `the_page_asks_the_empty_state_with_the_negated_row_count`:两条调用点(宽窗 + 窄窗)
都必须传 `!block.rows.is_empty()`,条数也钉住(多一条即红)。
- 两个变异已自证:传参取反 ⇒ 后一条红;CTA 挪支 ⇒ 前一条红(红原文见交付报告)。
## P3
- `java::target_major()` 改读并集快照:只有自动发现实例的用户原来会拿到默认 JDK17 而不是
该版本要求的 JDK(`profiles.json` 是空的)。有档案时顺序与旧写法一致。
- `instances()` 全量扫仍在 UI 线程——本轮不动,刷新时机与代价写进报告(塔另开一条)。
## 结构
`host/game.rs` 394 → 286 行:自动发现那一簇(痕迹判据 / 扫出来的版本 / 合成视图)搬到
`host/game/discovered.rs`(138 行),只为守 400 行硬线;`game::base_mode` 仍是同一条路径。
## 门禁
app-iced lib 973 passed / presenter all-targets 386 passed / i18n 36 passed /
fmt 0 / clippy -D warnings 0。
This commit is contained in:
1 parent
5479f25068
commit
c5009442c5
21 files changed
+494
-157
No files matched your search
@@ -99,6 +99,14 @@ pub fn mods_failure_line(
|
||||
) -> String {
|
||||
mods::failure_line(failure, Texts::of(catalog))
|
||||
}
|
||||
|
||||
/// 自动发现的实例按下「启动」时那句提示(**这一次**用的是哪一边的运行目录)。
|
||||
///
|
||||
/// 取值口径只有一处:[`Texts::discovered_run`],判据是快照里的 `isolation`
|
||||
/// (`shared` = 共用游戏根;其余 = 它自己的版本目录)。界面层不自己判盘。
|
||||
pub fn discovered_run_toast(catalog: unlucky_i18n::Catalog, isolation: &str) -> String {
|
||||
Texts::of(catalog).discovered_run(isolation).to_string()
|
||||
}
|
||||
pub use state::{LaunchForm, Message as InstanceMessage};
|
||||
pub(super) use text::Texts;
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ pub(super) fn detail_column<'a>(
|
||||
.spacing(ui.space(Space::X4))
|
||||
.width(Length::Fill);
|
||||
|
||||
if let Some(note) = discovered_note(ui, detail, texts) {
|
||||
body = body.push(note);
|
||||
}
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// M224:MODS 区排在启动参数**之前**——详情列是一个滚动区,而启动参数卡本身就有一屏高,
|
||||
// 排在它后面意味着「本地 mod 列表」永远在折叠线以下(M224 出图实测:1180×1000 完全看不见)。
|
||||
@@ -95,6 +98,9 @@ pub(super) fn narrow_detail<'a>(
|
||||
.spacing(ui.space(Space::X4))
|
||||
.width(Length::Fill);
|
||||
|
||||
if let Some(note) = discovered_note(ui, detail, texts) {
|
||||
body = body.push(note);
|
||||
}
|
||||
body = push_text(ui, metrics, body, detail);
|
||||
// 顺序与宽窗那一支一致:内容(MODS)在选项(启动参数)之前,理由见 `detail_column`。
|
||||
body = body.push(super::mods::section(ui, mods, forced, texts));
|
||||
@@ -229,6 +235,23 @@ fn detail_head<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 自动发现的实例多一句话:**运行目录由盘上已有的文件决定**(M273 r1)。
|
||||
///
|
||||
/// 具体的档位就在上面的 ISOLATION 那一格(共用目录 / 独立目录),这里补的是「为什么」——
|
||||
/// 用户看到「共用目录」时必须能回答「我明明在 HMCL 里给它装过 mod」。
|
||||
/// 判据只有 id 前缀([`unlucky_presenter::dto::is_discovered_instance`]),界面层不自己判盘。
|
||||
fn discovered_note<'a>(ui: &'a Ui, detail: &Detail, texts: Texts) -> Option<Element<'a, Message>> {
|
||||
unlucky_presenter::dto::is_discovered_instance(&detail.id).then(|| {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
texts.discovered_note(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 元信息网格:列数由 [`layout::meta_columns`](按正文列宽)决定 —— **1180 与 900 都是 2 列**。
|
||||
///
|
||||
/// 3 列需要 `3 × META_CARD_MIN_WIDTH + 2 × META_GAP` = 504px,而行宽上限是 424,**数学上不可行**;
|
||||
@@ -294,28 +317,43 @@ fn action_row<'a>(ui: &'a Ui, forced: Option<Interaction>, texts: Texts) -> Elem
|
||||
|
||||
/// 详情列的**空态**,两种含义分得很清楚:
|
||||
///
|
||||
/// | `has_instances` | 标题 / 说明 | 主行动 |
|
||||
/// |---|---|---|
|
||||
/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | 无 |
|
||||
/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) |
|
||||
/// | `has_instances` | 标题 / 说明 | 主行动 | 画在哪 |
|
||||
/// |---|---|---|---|
|
||||
/// | `true`(列表有东西,但没选中) | 「未选择实例」+ 「在左侧选一个」 | **无** | [`empty_not_selected`] |
|
||||
/// | `false`(一个实例都没有) | 「还没有实例」+ 「导入一个已经装在盘上的版本」 | **导入已有版本**(实心按钮) | [`empty_no_instances`] |
|
||||
///
|
||||
/// 为什么要分开(M273):用户现场 `instances=0`,却看到「未选择实例」——那句话在暗示
|
||||
/// 「你已经有的东西只是没点」,于是他找不到任何入口。一个都没有时必须给一条**看得见**的出路:
|
||||
/// 接管盘上已经装好的版本(向导的另一半是「检测到了 N 个外部版本」的候选列表)。
|
||||
/// 接管盘上已经装好的版本。
|
||||
///
|
||||
/// 两个分支各自成一个函数,不是为了好看:CTA 落在哪一支、另一支有没有动作,只能这样分开断言。
|
||||
/// 「传参有没有传反」由调用点那一行的源码断言守(`tests/empty_entry.rs`)——那是本函数看不见的
|
||||
/// 事实,r1 复审的变异(把 `!block.rows.is_empty()` 写成 `block.rows.is_empty()`)就是从这里溜的。
|
||||
pub(super) fn detail_empty<'a>(
|
||||
ui: &'a Ui,
|
||||
texts: Texts,
|
||||
has_instances: bool,
|
||||
) -> Element<'a, Message> {
|
||||
if has_instances {
|
||||
return frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
texts.empty_title(),
|
||||
texts.empty_body(),
|
||||
Vec::new(),
|
||||
);
|
||||
empty_not_selected(ui, texts)
|
||||
} else {
|
||||
empty_no_instances(ui, texts)
|
||||
}
|
||||
}
|
||||
|
||||
/// 有实例、但没选中:只说明「去左边点一个」,**不给动作**(动作在左列,点了就有)。
|
||||
fn empty_not_selected<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
|
||||
frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
texts.empty_title(),
|
||||
texts.empty_body(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 一个实例都没有:给一条主行动(导入已有版本 = 打开已有的接管向导)。
|
||||
fn empty_no_instances<'a>(ui: &'a Ui, texts: Texts) -> Element<'a, Message> {
|
||||
frame::empty_state(
|
||||
ui,
|
||||
IconKind::Instances,
|
||||
|
||||
@@ -42,7 +42,8 @@ fn the_empty_state_hands_out_the_adopt_intent() {
|
||||
/// 「未选择实例」敷衍——那句话在告诉用户「你已经有的东西只是没点」。
|
||||
#[test]
|
||||
fn the_empty_state_wires_the_cta_to_that_intent() {
|
||||
let body = source::function_body(include_str!("../detail.rs"), "fn detail_empty");
|
||||
let detail = include_str!("../detail.rs");
|
||||
let body = source::function_body(detail, "fn empty_no_instances");
|
||||
let cta = source::code_line(body, "Button::new(texts.adopt_cta()")
|
||||
.unwrap_or_else(|| panic!("空态里没有一行**代码**建那枚「导入已有版本」按钮:\n{body}"));
|
||||
assert!(
|
||||
@@ -62,6 +63,95 @@ fn the_empty_state_wires_the_cta_to_that_intent() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **CTA 只属于「一个都没有」那一支**:`has_instances == true` 的空态不许有任何动作。
|
||||
///
|
||||
/// r1 复审实测的缺口:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,于是「0 个实例」
|
||||
/// 反而走「没选中」那一支、**没有按钮**,而当时的三条用例全绿。这条与下一条就是为它补的:
|
||||
/// 这里钉「每个分支自己长什么样」,下一条钉「传参有没有传反」。
|
||||
///
|
||||
/// 抓得住:CTA 被挪到「没选中」那一支(用户有实例时反而看见「导入已有版本」);
|
||||
/// 「没选中」那一支被塞进动作(`Vec::new()` 被换掉);两个分支被写成一模一样。
|
||||
/// 抓不住:`frame::empty_state` 真的把那枚按钮画出来(`Element` 无内省,见 `pages::layout::source`)。
|
||||
#[test]
|
||||
fn the_cta_lives_only_in_the_no_instances_branch() {
|
||||
let detail = include_str!("../detail.rs");
|
||||
let selected = source::code_text(source::function_body(detail, "fn empty_not_selected"));
|
||||
let none = source::code_text(source::function_body(detail, "fn empty_no_instances"));
|
||||
|
||||
assert!(
|
||||
selected.contains("Vec::new()"),
|
||||
"「有实例但没选中」那一支不该有任何动作(动作在左列):\n{selected}"
|
||||
);
|
||||
for needle in ["adopt_cta", "adopt_action"] {
|
||||
assert!(
|
||||
!selected.contains(needle),
|
||||
"「有实例但没选中」那一支混进了 `{needle}`(CTA 挪支了):\n{selected}"
|
||||
);
|
||||
}
|
||||
for needle in ["adopt_cta()", "adopt_action()"] {
|
||||
assert!(
|
||||
none.contains(needle),
|
||||
"「一个实例都没有」那一支缺 `{needle}`(用户又找不到入口):\n{none}"
|
||||
);
|
||||
}
|
||||
|
||||
// 分派本身:`if has_instances` 走「没选中」,`else` 走带 CTA 的那一支。
|
||||
// 行首判据 ⇒ 写成 `if !has_instances` 立刻红(那种取反翻转下面两条顺序断言看不出来)。
|
||||
let dispatch = source::function_body(detail, "fn detail_empty");
|
||||
assert!(
|
||||
source::has_code_line(dispatch, "if has_instances {"),
|
||||
"`detail_empty` 必须以 `if has_instances {{` 分派(取反 = 两个空态互换):\n{dispatch}"
|
||||
);
|
||||
let code = source::code_text(dispatch);
|
||||
let if_at = code.find("if has_instances").expect("分派条件");
|
||||
let else_at = code.find("} else {").expect("else 分支");
|
||||
let selected_at = code
|
||||
.find("empty_not_selected(ui, texts)")
|
||||
.expect("没选中分支");
|
||||
let none_at = code
|
||||
.find("empty_no_instances(ui, texts)")
|
||||
.expect("一个都没有分支");
|
||||
assert!(
|
||||
if_at < selected_at && selected_at < else_at,
|
||||
"`has_instances` 为真必须走 `empty_not_selected`:\n{code}"
|
||||
);
|
||||
assert!(
|
||||
else_at < none_at,
|
||||
"`has_instances` 为假必须走 `empty_no_instances`:\n{code}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **调用点**:实例页量「有没有实例」的那一行不许传反。
|
||||
///
|
||||
/// 这是 r1 复审的变异原样:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,上面的
|
||||
/// 分支断言照样绿(两个分支各自都还对),但**用户看到的是错的那一支**。所以判据必须落在
|
||||
/// 调用点上——宽窗与窄窗各有一条,两条都要有 `!`。
|
||||
///
|
||||
/// 抓得住:传参取反(`!` 被删);只改掉一处(宽窗对了、窄窗忘了);新增第三条调用点却传了别的
|
||||
/// 值(条数断言只认 2 条,多出来就红)。
|
||||
/// 抓不住:等价写法(`block.rows.len() == 0`)——本仓构建链一行一条,判据按行首取。
|
||||
#[test]
|
||||
fn the_page_asks_the_empty_state_with_the_negated_row_count() {
|
||||
let page = include_str!("../../instances.rs");
|
||||
let calls: Vec<String> = source::code_lines(page)
|
||||
.into_iter()
|
||||
.filter(|line| line.contains("detail_empty("))
|
||||
.map(|line| line.trim().to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
"实例页应当只有两条空态调用点(宽窗 + 窄窗),实际:{calls:?}"
|
||||
);
|
||||
for line in &calls {
|
||||
assert!(
|
||||
line.contains("!block.rows.is_empty()"),
|
||||
"调用点把「有没有实例」传反了(`!` 丢了 ⇒ 一个都没有时反而没有按钮):{line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。
|
||||
///
|
||||
/// 判据走 `code_text`(注释已剥):把那条 match 臂注释掉、或改成只打一行日志,这里就红——
|
||||
|
||||
@@ -48,6 +48,9 @@ const ALLOWED: &[&str] = &[
|
||||
// M247:`zip` 是**归档格式名**(备份导出成什么,玩家拿去给别的东西解压时看的也是这个词)。
|
||||
// 中文界面上写「压缩包」会让「备份出来的是 .zip 吗」这个问题没有答案。
|
||||
"zip",
|
||||
// M273 r1:`.minecraft` 是**目录名**(用户要照着去文件管理器里找的那一层)。共用目录那句
|
||||
// 必须原样写它,翻成「游戏根目录」会让用户对不上盘上那个文件夹。
|
||||
"minecraft",
|
||||
];
|
||||
|
||||
/// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。
|
||||
|
||||
@@ -97,6 +97,20 @@ impl Texts {
|
||||
pub(super) fn adopt_cta(self) -> &'static str {
|
||||
self.text(Key::InstancesAdoptCta)
|
||||
}
|
||||
/// 自动发现实例的运行目录规则(详情页常驻的一句话)。
|
||||
pub(super) fn discovered_note(self) -> &'static str {
|
||||
self.text(Key::InstancesDiscoveredNote)
|
||||
}
|
||||
/// 自动发现实例**这一次**按哪一边跑(启动时弹的那一句)。
|
||||
///
|
||||
/// 判据只有 `isolation` 这一档:`shared` = 共用游戏根,其余(`isolated`)= 它自己的版本目录。
|
||||
pub(super) fn discovered_run(self, isolation: &str) -> &'static str {
|
||||
if isolation == "shared" {
|
||||
self.text(Key::InstancesDiscoveredRunShared)
|
||||
} else {
|
||||
self.text(Key::InstancesDiscoveredRunIsolated)
|
||||
}
|
||||
}
|
||||
pub(super) fn narrow_note(self) -> &'static str {
|
||||
self.text(Key::InstancesNarrowNote)
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ mod tests;
|
||||
pub use download::{cancelled_toast, Category, Download, InstallDraft, Loader};
|
||||
pub(crate) use instances::demo_form as demo_launch_form;
|
||||
pub use instances::{
|
||||
mods_failure_line, mods_from_state, saves_delete_prompt, saves_failure_line, saves_from_state,
|
||||
InstanceMessage, LaunchForm, Mods, Saves,
|
||||
discovered_run_toast, mods_failure_line, mods_from_state, saves_delete_prompt,
|
||||
saves_failure_line, saves_from_state, InstanceMessage, LaunchForm, Mods, Saves,
|
||||
};
|
||||
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
|
||||
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
|
||||
|
||||
Reference in new issue
Block a user