From a36e17a5d38de57550c6a269fc0a8fd138a24c96 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 14:07:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20=E7=82=B9=E9=80=89=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E6=A1=A3=E7=AB=8B=E5=88=BB=E6=98=BE=E7=A4=BA=E5=90=8E?= =?UTF-8?q?=E6=9E=9C=E5=8E=9F=E6=96=87=EF=BC=8C=E7=99=BB=E8=AE=B0=E5=9C=A8?= =?UTF-8?q?=E9=A3=9E=E6=97=B6=E4=B8=8D=E5=90=83=E5=85=B3=E9=97=AD=EF=BC=88?= =?UTF-8?q?M223=20=E8=87=AA=E6=A3=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处自己复审抓到的问题: 1. 契约 §5.2 要求 Shared/VersionDir 被**点选**就展示 target_notes() 原文,而上一版要等 用户再点一次「预检」才出现。现在点选这两档会顺手发一次**零写盘**的预检,结果落在 Pick 段当提示读(不把用户甩到确认页:确认仍由显式的「预检」按钮触发,Form::checked)。 改档之后旧 plan 因目标档不一致而不再显示(Form::fresh_plan),避免画上一档的后果原文。 2. 「关闭」在登记在飞时被忽略:core 那条线程已经开跑,关面板会让 Done 事件被代次丢掉, 用户看到的就是「实例写进去了,界面什么都没说」。 用例:a_foreign_target_shows_its_notes_without_jumping_to_confirm / choosing_a_foreign_target_previews_but_waits_for_the_user / closing_is_ignored_while_the_registration_is_in_flight。 --- crates/app-iced/src/app/adopt.rs | 67 ++++++++++++++++++++----- crates/app-iced/src/app/adopt/pick.rs | 10 +++- crates/app-iced/src/app/adopt/tests.rs | 52 +++++++++++++++++-- crates/app-iced/src/app/update/adopt.rs | 53 ++++++++++++++++++- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/crates/app-iced/src/app/adopt.rs b/crates/app-iced/src/app/adopt.rs index 94cba79..d64ced2 100644 --- a/crates/app-iced/src/app/adopt.rs +++ b/crates/app-iced/src/app/adopt.rs @@ -48,7 +48,9 @@ use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space as Sp; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key, Lang}; -use unlucky_presenter::dto::{AdoptCandidateView, AdoptStage, AdoptState, AdoptTargetView}; +use unlucky_presenter::dto::{ + AdoptCandidateView, AdoptPlanView, AdoptStage, AdoptState, AdoptTargetView, +}; use crate::app::components::buttons::{self, Button}; use crate::app::components::feedback::dialog::scrim; @@ -102,6 +104,13 @@ pub struct Form { pub target: AdoptTargetView, /// 在等在飞的那一条命令 pub busy: Busy, + /// 用户已经点过「预检」(没点过就留在 Pick 段,哪怕后台已经顺手算过一次落点)。 + /// + /// 为什么要有这一位:共享档 / 版本目录档一旦被点选,界面就要立刻显示 `target_notes()` 的 + /// **后果原文**(契约 §5.2)——那是 core 唯一会产出这段文字的地方,所以点选时顺手发一次 + /// **零写盘**的预检。但那一次结果不该把用户甩到「可以导入」的确认页:确认必须由显式的 + /// `Check` 触发。于是 `checked` 记住「这次预检是不是用户要的」。 + pub checked: bool, } impl Form { @@ -124,6 +133,30 @@ impl Form { self.busy = Busy::Idle; } + /// 当前该画哪一段:`Preview` 只在用户**点过预检**之后才算数,其余按 `AdoptState::stage()`。 + /// + /// 这条收窄是为了「点选共享档 ⇒ 立刻看到后果原文」而不把用户直接甩到确认页: + /// 自动预检的结果落在 Pick 段里当**提示**读,用户点「预检」之后才进确认段。 + #[must_use] + pub fn stage(&self, adopt_state: &AdoptState) -> AdoptStage { + match adopt_state.stage() { + AdoptStage::Preview if !self.checked => AdoptStage::Pick, + other => other, + } + } + + /// 后台算出来、且**对得上当前选择**的预检(对不上 = 上一档的过期结果,一律不画)。 + /// + /// 判据是「目标档一致」:用户改了目标档之后,旧 plan 的 `notes` 说的是别的目录, + /// 拿它当后果原文就是撒谎。版本一致性由 [`Self::candidate`] 那一路保证(选中即重扫)。 + #[must_use] + pub fn fresh_plan<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptPlanView> { + adopt_state + .plan + .as_ref() + .filter(|plan| plan.target == self.target) + } + /// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。 #[must_use] pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> { @@ -206,24 +239,25 @@ fn card<'a>( adopt_state: &'a AdoptState, catalog: Catalog, ) -> Element<'a, Message> { + let stage = form.stage(adopt_state); let body = column![ frame::text_role( ui, TypeRole::HeadlineSmall, true, - title_text(adopt_state, catalog), + title_text(stage, catalog), ui.color(Role::OnSurface), ) .map(Message::Page), wrapped( ui, - subtitle(adopt_state, catalog), + subtitle(stage, adopt_state, catalog), ui.color(Role::OnSurfaceVariant) ), frame::divider(ui).map(Message::Page), - middle(ui, form, adopt_state, catalog), + middle(ui, form, adopt_state, stage, catalog), frame::divider(ui).map(Message::Page), - footer(ui, form, adopt_state, catalog), + footer(ui, form, adopt_state, stage, catalog), ] .spacing(ui.space(Sp::X3)) .width(Length::Fill); @@ -241,8 +275,8 @@ fn card<'a>( } /// 段标题(四段各一句)。 -fn title_text(adopt_state: &AdoptState, catalog: Catalog) -> &'static str { - match adopt_state.stage() { +fn title_text(stage: AdoptStage, catalog: Catalog) -> &'static str { + match stage { AdoptStage::Pick => catalog.text(Key::AdoptTitle), AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle), AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle), @@ -251,8 +285,8 @@ fn title_text(adopt_state: &AdoptState, catalog: Catalog) -> &'static str { } /// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。 -fn subtitle(adopt_state: &AdoptState, catalog: Catalog) -> String { - match adopt_state.stage() { +fn subtitle(stage: AdoptStage, adopt_state: &AdoptState, catalog: Catalog) -> String { + match stage { AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(), AdoptStage::Preview => adopt_state .plan @@ -279,11 +313,18 @@ fn middle<'a>( ui: &'a Ui, form: &'a Form, adopt_state: &'a AdoptState, + stage: AdoptStage, catalog: Catalog, ) -> Element<'a, Message> { - match adopt_state.stage() { - AdoptStage::Pick => pick::pick(ui, form, adopt_state.scan.as_ref(), catalog), - AdoptStage::Preview => panes::preview(ui, adopt_state.plan.as_ref(), catalog), + match stage { + AdoptStage::Pick => pick::pick( + ui, + form, + adopt_state.scan.as_ref(), + form.fresh_plan(adopt_state), + catalog, + ), + AdoptStage::Preview => panes::preview(ui, form.fresh_plan(adopt_state), catalog), AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog), AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog), } @@ -294,9 +335,9 @@ fn footer<'a>( ui: &'a Ui, form: &'a Form, adopt_state: &'a AdoptState, + stage: AdoptStage, catalog: Catalog, ) -> Element<'a, Message> { - let stage = adopt_state.stage(); let busy = form.busy.waiting(); let primary: Element<'a, Message> = match stage { diff --git a/crates/app-iced/src/app/adopt/pick.rs b/crates/app-iced/src/app/adopt/pick.rs index 3a5cace..2ac06d1 100644 --- a/crates/app-iced/src/app/adopt/pick.rs +++ b/crates/app-iced/src/app/adopt/pick.rs @@ -14,7 +14,7 @@ use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space as Sp; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; -use unlucky_presenter::dto::{AdoptCandidateView, AdoptScanView, AdoptTargetView}; +use unlucky_presenter::dto::{AdoptCandidateView, AdoptPlanView, AdoptScanView, AdoptTargetView}; use crate::app::components::inputs::{text_field, Field}; use crate::app::components::selection::{chip, Chip}; @@ -30,6 +30,7 @@ pub(super) fn pick<'a>( ui: &'a Ui, form: &'a Form, scan: Option<&'a AdoptScanView>, + plan: Option<&'a AdoptPlanView>, catalog: Catalog, ) -> Element<'a, Message> { let Some(scan) = scan else { @@ -57,6 +58,13 @@ pub(super) fn pick<'a>( body = body.push(frame::divider(ui).map(Message::Page)); body = body.push(label(ui, catalog.text(Key::AdoptTargetLabel))); body = body.push(target_row(ui, form, catalog)); + // 后果原文(契约 §5.2):共享档 / 版本目录档一被点选就显示 core `target_notes()` 的原文—— + // 这一条来自**零写盘**的自动预检(见 `AdoptMessage::Target` 的落点),不是界面另写的一句。 + if let Some(plan) = plan { + for note in &plan.notes { + body = body.push(wrapped(ui, note.clone(), ui.color(Role::OnSurfaceVariant))); + } + } body = body.push(text_field( ui, Field::new(form.name.as_str()).label(catalog.text(Key::AdoptNameLabel)), diff --git a/crates/app-iced/src/app/adopt/tests.rs b/crates/app-iced/src/app/adopt/tests.rs index 87d2a6b..082ff7f 100644 --- a/crates/app-iced/src/app/adopt/tests.rs +++ b/crates/app-iced/src/app/adopt/tests.rs @@ -90,11 +90,11 @@ fn the_four_stages_project_from_the_event_state() { // Pick assert_eq!(state.stage(), AdoptStage::Pick); assert_eq!( - title_text(&state, catalog), + title_text(state.stage(), catalog), catalog.text(Key::AdoptTitle), "标题走目录" ); - assert!(subtitle(&state, catalog).contains("不复制")); + assert!(subtitle(state.stage(), &state, catalog).contains("不复制")); // Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改 let note = @@ -136,7 +136,7 @@ fn the_four_stages_project_from_the_event_state() { "两条问题一条都不许吞" ); assert_eq!( - title_text(&state, catalog), + title_text(state.stage(), catalog), catalog.text(Key::AdoptFailureTitle) ); @@ -152,7 +152,7 @@ fn the_four_stages_project_from_the_event_state() { }))); assert_eq!(state.stage(), AdoptStage::Done); assert_eq!( - title_text(&state, catalog), + title_text(state.stage(), catalog), catalog.text(Key::AdoptDoneTitle) ); } @@ -199,3 +199,47 @@ fn both_catalogs_have_the_wizard_wording() { let hint = Lang::En.catalog().text(Key::AdoptTargetSharedHint); assert!(hint.contains(".minecraft"), "{hint}"); } + +/// 「点选共享档 ⇒ 立刻看到后果原文」但**不**把用户甩到确认页: +/// 预检结果到了也只是 Pick 段里的提示,直到用户点过「预检」(`checked`) 才进 Preview。 +/// +/// 抓得住:把 `Form::stage` 写回直接取 `AdoptState::stage()`(点一次共享档就跳到确认页, +/// 实例名还没来得及改);或忘了 `fresh_plan` 的目标一致性判据(改档之后画上一档的后果原文)。 +#[test] +fn a_foreign_target_shows_its_notes_without_jumping_to_confirm() { + let mut state = state_with_scan(); + let note = + "运行目录 = /game(共享档):mods/saves/config 会落在别人的 .minecraft 里".to_string(); + state.apply(AdoptEvent::Planned(Box::new(AdoptPlanView { + version_id: "1.20.1".to_string(), + name: "1.20.1".to_string(), + target: AdoptTargetView::Shared, + isolation: AdoptIsolationView::Shared, + run_dir: std::path::PathBuf::from("/game"), + notes: vec![note.clone()], + }))); + + let mut form = Form { + open: true, + selected: Some("1.20.1".to_string()), + target: AdoptTargetView::Shared, + ..Form::default() + }; + + // 自动预检回来但用户没点「预检」:仍是 Pick 段,notes 当作提示可读 + assert_eq!(form.stage(&state), AdoptStage::Pick); + assert_eq!( + form.fresh_plan(&state) + .map(|plan| plan.notes.first().cloned()), + Some(Some(note.clone())), + "后果原文必须能取到" + ); + + // 点过「预检」之后才进确认段 + form.checked = true; + assert_eq!(form.stage(&state), AdoptStage::Preview); + + // 换了目标档:上一档的预检作废(不再当后果原文画) + form.target = AdoptTargetView::VersionDir; + 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 c5aee94..597d1d0 100644 --- a/crates/app-iced/src/app/update/adopt.rs +++ b/crates/app-iced/src/app/update/adopt.rs @@ -35,6 +35,11 @@ impl App { self.dispatch_adopt(AdoptCommand::Scan); } AdoptMessage::Close => { + // 登记在飞时不吃「关闭」:core 那条线程已经开跑了,写了盘却不回报 Done + // 就会出现「实例已存在但界面什么都没说」——等它交卷(几十毫秒级)。 + if self.adopt_form.busy == Busy::Importing { + return Task::none(); + } self.adopt_form.close(); self.dispatch_adopt(AdoptCommand::Dismiss); } @@ -42,9 +47,22 @@ impl App { self.adopt_form.selected = Some(version_id); } AdoptMessage::Name(name) => self.adopt_form.name = name, - AdoptMessage::Target(target) => self.adopt_form.target = target, + AdoptMessage::Target(target) => { + self.adopt_form.target = target; + // 换了目标档 ⇒ 上一档的预检作废(确认按钮不该继续亮着) + self.adopt_form.checked = false; + // 共享档 / 版本目录档会写进**别人的目录**:一被点选就把后果原文拿出来 + // (零写盘的预检,见 `Form::stage` 的收窄:这一次结果落在 Pick 段当提示读)。 + if target.touches_foreign_dir() { + if let Some(request) = self.adopt_request() { + self.adopt_form.busy = Busy::Checking; + self.dispatch_adopt(AdoptCommand::Preview(Box::new(request))); + } + } + } AdoptMessage::Check => { if let Some(request) = self.adopt_request() { + self.adopt_form.checked = true; self.adopt_form.busy = Busy::Checking; self.dispatch_adopt(AdoptCommand::Preview(Box::new(request))); } @@ -58,6 +76,7 @@ impl App { AdoptMessage::Back => { // 退回候选列表 = 重新扫一次:`scan` 会把上一轮的预检/失败一起作废, // 于是界面回到 Pick 段,不需要再给 core 加一个「清失败」的命令。 + self.adopt_form.checked = false; self.adopt_form.busy = Busy::Scanning; self.dispatch_adopt(AdoptCommand::Scan); } @@ -173,4 +192,36 @@ mod tests { )); assert!(app.state.adopt.scan.is_none()); } + + /// 共享档一被点选就发一条**预检**(零写盘),但**不**置 `checked`——确认页要用户点「预检」。 + /// + /// 抓得住:`Target` 分支忘了发预检(后果原文永远不出现,契约 §5.2 失守)、 + /// 或者顺手把 `checked` 也置上(点一下档位就跳到确认页)。 + #[test] + fn choosing_a_foreign_target_previews_but_waits_for_the_user() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string())); + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::Shared)); + assert_eq!(app.adopt_form.busy, Busy::Checking); + assert!(!app.adopt_form.checked, "确认页要等用户点「预检」"); + + // 缺省档不触发自动预检(它不会写进别人的目录) + let _ = app.on_adopt(AdoptMessage::Target(AdoptTargetView::InstanceDir)); + assert!(!app.adopt_form.checked); + } + + /// 登记在飞时「关闭」被忽略:core 已经在写了,关掉面板只会让 Done 无处可去。 + /// + /// 抓得住:Close 无条件清表单并 `Dismiss`(实例写成功了,界面却什么都没说)。 + #[test] + fn closing_is_ignored_while_the_registration_is_in_flight() { + let mut app = headless(); + let _ = app.on_adopt(AdoptMessage::Open); + let _ = app.on_adopt(AdoptMessage::Select("1.20.1".to_string())); + let _ = app.on_adopt(AdoptMessage::Check); + app.adopt_form.busy = Busy::Importing; + let _ = app.on_adopt(AdoptMessage::Close); + assert!(app.adopt_form.open, "登记在飞时不许把面板关掉"); + } }