diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs index bb73656..b6db869 100644 --- a/crates/app-iced/src/app/adopt.rs +++ b/crates/app-iced/src/app/adopt.rs @@ -144,16 +144,23 @@ impl Form { } } - /// 后台算出来、且**对得上当前选择**的预检(对不上 = 上一档的过期结果,一律不画)。 + /// 后台算出来、且**对得上当前选择**的预检(对不上 = 过期的结果,一律不画)。 /// - /// 判据是「目标档一致」:用户改了目标档之后,旧 plan 的 `notes` 说的是别的目录, - /// 拿它当后果原文就是撒谎。版本一致性由 [`Self::candidate`] 那一路保证(选中即重扫)。 + /// 两个判据缺一不可: + /// - **目标档一致**:改了档之后旧 plan 的 `notes` 说的是别的目录; + /// - **版本 id 一致**:换了版本之后旧 plan 的 `notes` 说的是别的版本目录 + /// (版本目录档的后果原文里带 `versions/`,拿它当新版本的后果就是撒谎)。 + /// + /// 没有选中版本时也返回 `None`:那一刻没有任何版本可言,画谁的预检都不成立。 + /// 这两条都不是「选中即重扫」带来的——`Select` 只改本地字段、不发命令(落点见 + /// `app/update/adopt.rs`),所以过期判定只能在这里做。 #[must_use] pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> { + let selected = self.selected.as_deref()?; adopt_state .plan .as_ref() - .filter(|plan| plan.target == self.target) + .filter(|plan| plan.target == self.target && plan.version_id == selected) } /// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。 diff --git a/crates/app-iced/src/app/adopt/shell.rs b/crates/app-iced/src/app/adopt/shell.rs index 1f504a5..35a6051 100644 --- a/crates/app-iced/src/app/adopt/shell.rs +++ b/crates/app-iced/src/app/adopt/shell.rs @@ -6,8 +6,19 @@ //! | [`title_text`] / [`subtitle`] | 四段的标题与一句说明(文案全部来自 i18n 目录) | //! | [`footer`] | 按段给按钮组合;在等回程时主按钮禁用 | //! -//! 纯搬移:函数体一行未改,只是换了文件(`app/adopt.rs` 落笔 412 行越过 `components::gate` -//! 的 400 行硬线)。调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。 +//! ## 这个文件不是「纯搬移」 +//! +//! 它**起于**一次搬移:`app/adopt.rs` 落笔 412 行越过 `components::gate` 的 400 行硬线, +//! 于是把卡壳 / 标题 / 页脚搬到这里。但**同一个提交里**这三块还改过行为与几何,逐条如下 +//! (复审对 diff 时按这几条看,别按「一行未改」看): +//! +//! | 改了什么 | 为什么 | +//! |---|---| +//! | `card` 拆成「定高 + 中间区可滚」(Pick / Failed)与「按内容收高」(Preview / Done)两种几何 | 原来只给 `max_height`,内容一高就被裁:页脚按钮与实例名整排不可见(出图时抓到) | +//! | `footer` 的「关闭」只在 Pick / Preview 出现 | Failed / Done 的主按钮就是关闭,再给一枚文字关闭就是两枚一模一样的按钮 | +//! | `subtitle` 的 Preview 文案改成「版本名 · 目标档」 | 原来打「隔离档 · 目标档」,同屏两个「共用目录」 | +//! +//! 调用方仍是 `app/adopt.rs` 的 `layer` 与 `middle`。 use iced::widget::{column, container, row, scrollable, Space}; use iced::{Background, Element, Length}; diff --git a/crates/app-iced/src/app/adopt/shot.rs b/crates/app-iced/src/app/adopt/shot.rs index 99df6bd..cd48a14 100644 --- a/crates/app-iced/src/app/adopt/shot.rs +++ b/crates/app-iced/src/app/adopt/shot.rs @@ -22,7 +22,7 @@ //! ## 怎么用 //! //! ```text -//! ULL_ADOPT_STATE=pick|empty|preview|shared|failed|done \ +//! ULL_ADOPT_STATE=pick|pick-selected|empty|preview|shared|failed|done \ //! unlucky-launcher-iced.exe --software-renderer --screenshot [--screenshot-theme dark] //! ``` //! @@ -65,6 +65,12 @@ pub(crate) fn seed(name: &str) -> Option<(Form, AdoptState)> { state.apply(AdoptEvent::Scan(Box::new(scan_view()))); form.busy = Busy::Idle; } + // 扫描完成、**已选中一个可导入的版本**(选中行底色 + 预检按钮启用态) + "pick-selected" => { + state.apply(AdoptEvent::Scan(Box::new(scan_view()))); + form.selected = Some("1.20.1".to_string()); + form.busy = Busy::Idle; + } // 游戏根下真没有版本(空态) "empty" => { state.apply(AdoptEvent::Scan(Box::new(AdoptScanView { diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs index 607c047..2697f6f 100644 --- a/crates/app-iced/src/app/adopt/tests.rs +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -243,3 +243,45 @@ fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() { form.target = AdoptTargetView::VersionDir; assert!(form.fresh_plan(&state).is_none(), "过期的预检不许画"); } + +/// **P2-2**:`fresh_plan` 必须按**版本 id** 过期,不只是目标档。 +/// +/// 场景:先选 1.20.1 + 版本目录档(notes 说 `versions/1.20.1`),再改选 1.19.4——目标档没变, +/// 旧的预检结果若不按版本过滤就会继续当「这个版本的后果原文」画出来,那是一句假话。 +/// +/// 抓得住:`fresh_plan` 只看 `plan.target == self.target`(第一次修法就是这样漏的)。 +/// 抓不住:core 真的会不会为 1.19.4 算出同一段 notes(那是 `target_notes()` 的活)。 +#[test] +fn fresh_plan_expires_when_the_selected_version_changes() { + let mut state = state_with_scan(); + state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target: AdoptTargetView::VersionDir, + isolation: AdoptIsolationView::Isolated, + run_dir: std::path::PathBuf::from("/game/versions/1.20.1"), + notes: vec!["运行目录 = 版本目录 /game/versions/1.20.1".to_string()], + }))); + + let mut form = Form { + open: true, + selected: Some("1.20.1".to_string()), + target: AdoptTargetView::VersionDir, + checked: true, + ..Form::default() + }; + assert!( + form.fresh_plan(&state).is_some(), + "对得上版本与目标档时应当可读" + ); + + // 改选另一个版本:目标档没变,但旧预检说的是别的版本的目录 + form.selected = Some("1.19.4".to_string()); + assert!( + form.fresh_plan(&state).is_none(), + "换了版本之后旧预检必须过期(否则画的是 1.20.1 的后果原文)" + ); + // 没选版本时同样不该画(`None` = 没有任何版本的预检可言) + form.selected = None; + assert!(form.fresh_plan(&state).is_none()); +} diff --git a/crates/app-iced/src/app/update/adopt.rs b/crates/app-iced/src/app/update/adopt.rs index 984d9ba..d63c3c9 100644 --- a/crates/app-iced/src/app/update/adopt.rs +++ b/crates/app-iced/src/app/update/adopt.rs @@ -48,6 +48,12 @@ impl App { } AdoptMessage::Name(name) => self.adopt_form.name = name, AdoptMessage::Target(target) => { + // 登记在飞时不吃「换档」:它会派一条零写盘的预览把 `adopt_epoch` +1, + // 于是在飞的 Confirm 交卷时 Done 被代次丢掉——实例写进去了,界面什么都没说。 + // 连本地档位也不改:界面上显示 A 档、盘上写的是 B 档,是最糟的一种不一致。 + if self.adopt_form.busy == Busy::Importing { + return Task::none(); + } self.adopt_form.target = target; // 换了目标档 ⇒ 上一档的预检作废(确认按钮不该继续亮着) self.adopt_form.checked = false; @@ -74,6 +80,11 @@ impl App { } } AdoptMessage::Back => { + // 同 Close / Target:登记在飞时「返回」会把 `adopt_epoch` +1(它派 Scan), + // 在飞 Confirm 的 Done 就再也到不了界面。 + if self.adopt_form.busy == Busy::Importing { + return Task::none(); + } // 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废, // 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。 self.adopt_form.checked = false; @@ -124,8 +135,13 @@ impl App { #[cfg(test)] mod tests { + use std::cell::RefCell; + use std::rc::Rc; + use unlucky_design::color::SchemeFlavor; - use unlucky_presenter::dto::{AdoptEvent, AdoptTargetView}; + use unlucky_presenter::dto::{ + AdoptCommand, AdoptEvent, AdoptTargetView, AppCommand, AppEvent, Backend, + }; use unlucky_presenter::mock::MockBackend; use unlucky_presenter::settings_bridge::SettingsBridge; @@ -150,6 +166,52 @@ mod tests { ) } + /// 记账后端:把 `dispatch` 收到的命令留在台账里(`MockBackend` 只认自己那几条, + /// 「有没有派出去」根本看不见——而 P2-1 的判据正是「不许派」)。 + #[derive(Debug, Default)] + struct RecordingBackend { + dispatched: Rc>>, + } + + impl Backend for RecordingBackend { + fn drain_events(&mut self) -> Vec { + Vec::new() + } + + fn dispatch(&mut self, command: AppCommand) { + self.dispatched.borrow_mut().push(command); + } + } + + /// 带台账的 App(返回台账句柄,测试直接查「派了什么」)。 + fn recording() -> (App, Rc>>) { + let dispatched: Rc>> = Rc::new(RefCell::new(Vec::new())); + let app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(RecordingBackend { + dispatched: Rc::clone(&dispatched), + }), + SettingsBridge::memory_only(), + ); + (app, dispatched) + } + + /// 台账里有没有派过某条 adopt 命令(只认命令种类,不看载荷内容)。 + fn dispatched_adopt(ledger: &Rc>>, want: &str) -> bool { + ledger.borrow().iter().any(|command| match command { + AppCommand::Adopt(command) => match (&**command, want) { + (AdoptCommand::Scan, "scan") => true, + (AdoptCommand::Preview(_), "preview") => true, + (AdoptCommand::Confirm(_), "confirm") => true, + (AdoptCommand::Dismiss, "dismiss") => true, + _ => false, + }, + _ => false, + }) + } + /// 打开向导 = 表单进入「在等扫描」+ 派一条 `Scan`。 /// /// 抓得住:`Open` 没派命令(面板永远停在「正在扫描」)、或忘了置 `Busy::Scanning`(没有等待态)。 @@ -241,4 +303,51 @@ mod tests { let _ = app.on_adopt(AdoptMessage::Close); assert!(app.adopt_form.open, "登记在飞时不许把面板关掉"); } + + /// **P2-1**:登记在飞时,除了 Close,「返回」与「换目标档」也必须早退。 + /// + /// 三条都是同一条病:它们都会派一条新命令把 `adopt_epoch` +1,于是在飞 Confirm 的 + /// `Done` 回来时被代次丢掉 —— 实例**写进去了**,界面却什么都没说(Close 那条早就守住了, + /// 唯独这两支漏了)。 + /// + /// 抓得住:`Back` 派了 `Scan`(旧 `Done` 被丢)、`Target` 派了 `Preview`(同上), + /// 以及「守卫写了但写在改本地状态之后」(表单被改成新档位,而盘上写的是旧档位)。 + /// 抓不住:host 侧代次比较的真实时序(那在 presenter 的 `host/adopt` 那层)。 + #[test] + fn back_and_target_are_ignored_while_the_registration_is_in_flight() { + let (mut app, ledger) = recording(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string())); + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir)); + // 走到「确认」那一步:真的派一条 Confirm 出去 + let _ = app.on_adopt(AdoptMessage::Check); + let _ = app.on_adopt(AdoptMessage::Confirm); + assert!( + dispatched_adopt(&ledger, "confirm"), + "前提:确认要真的派出去" + ); + assert_eq!(app.adopt_form.busy, Busy::Importing); + ledger.borrow_mut().clear(); + + // 在飞期间点「返回」:不许派 Scan + let _ = app.on_adopt(AdoptMessage::Back); + assert!( + !dispatched_adopt(&ledger, "scan"), + "登记在飞时 Back 不许派 Scan(会把 Done 的代次顶掉)" + ); + assert_eq!(app.adopt_form.busy, Busy::Importing, "等待态不许被改掉"); + + // 在飞期间换目标档:不许派 Preview,也不许改本地档位 + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared)); + assert!( + !dispatched_adopt(&ledger, "preview"), + "登记在飞时换档不许派 Preview(会把 Done 的代次顶掉)" + ); + assert_eq!( + app.adopt_form.target, + AdoptTargetView::InstanceDir, + "挡下来的点击不该改出界面与盘上不一致的档位" + ); + assert_eq!(app.adopt_form.busy, Busy::Importing); + } }