P2-1(行为):`Busy::Importing` 的早退只盖了 Close。「返回」会派 Scan、「换目标档」会派 零写盘 Preview,两条都把 `adopt_epoch` +1,于是在飞的 Confirm 交卷时 Done 被代次丢掉—— 实例写进去了,界面什么都没说(正是 Close 守卫要防的形状)。两处补同款早退,且守卫写在 改本地状态**之前**:挡下来的换档不该把界面改成 B 档而盘上写的是 A 档。 P2-2(显示陈旧):`Form::fresh_plan` 原来只按目标档过期。先选 1.20.1 + 版本目录档,再改选 1.19.4,目标档没变,旧预检的 notes(说的是 `versions/1.20.1`)会继续当新版本的后果原文画。 现在连 `plan.version_id == selected` 一起判;没选中版本时也返回 None。同处那句 「版本一致性由 Self::candidate 那一路保证(选中即重扫)」与代码不符(Select 只改本地字段、 不发命令),改成如实说明过期判定只能在这里做。 P2-3(文档):`app/adopt/shell.rs` 的「纯搬移:函数体一行未改」不成立——同一提交里卡壳几何、 页脚去重、预览摘要都改过。改成逐条列出改了什么与为什么。 用例(两条都是先写红再修,红时 panic 信息就是错在哪): - back_and_target_are_ignored_while_the_registration_is_in_flight(记账后端断言「不许派 Scan / 不许派 Preview」);红:`登记在飞时 Back 不许派 Scan(会把 Done 的代次顶掉)`; - fresh_plan_expires_when_the_selected_version_changes;红:`换了版本之后旧预检必须过期 (否则画的是 1.20.1 的后果原文)`。 顺带补图:`ULL_ADOPT_STATE` 加 `pick-selected` 一档(选中行底色 + 预检启用态), 出图 `DevDocs/artifacts/m223/adopt-pick-selected-light.png`(r1 复审说这格只有代码没截图)。
288 lines
11 KiB
Rust
288 lines
11 KiB
Rust
//! `app::adopt` 的用态用例(M223):表单态 + 四段投影。
|
||
//!
|
||
//! 抓得住:
|
||
//! - `Form` 的生命周期(open 清上一轮 / close 全清 / settled 解除等待);
|
||
//! - `candidate()` 在选中项不在列表里时返回 `None`(界面自动退回「请选择」);
|
||
//! - 四段的文案真源(标题/后果原文/问题逐条)能从 `AdoptState` 投影出来。
|
||
//!
|
||
//! 抓不住:**像素**(那是 D30 的截图通道);也不抓 core 的判据(那是 `host/adopt/tests.rs`)。
|
||
|
||
use unlucky_i18n::{Key, Lang};
|
||
use unlucky_presenter::dto::{
|
||
AdoptCandidateView, AdoptDoneView, AdoptEvent, AdoptFailureView, AdoptIsolationView,
|
||
AdoptPlanView, AdoptProblemView, AdoptScanView, AdoptStage, AdoptState, AdoptTargetView,
|
||
};
|
||
|
||
use super::*;
|
||
|
||
fn candidate(version_id: &str, problems: Vec<AdoptProblemView>) -> AdoptCandidateView {
|
||
AdoptCandidateView {
|
||
version_id: version_id.to_string(),
|
||
jar_present: true,
|
||
inherits: Vec::new(),
|
||
registered: Vec::new(),
|
||
problems,
|
||
}
|
||
}
|
||
|
||
fn state_with_scan() -> AdoptState {
|
||
let mut state = AdoptState::default();
|
||
state.apply(AdoptEvent::Scan(Box::new(AdoptScanView {
|
||
versions_dir: std::path::PathBuf::from("/game/versions"),
|
||
candidates: vec![candidate("1.20.1", Vec::new())],
|
||
})));
|
||
state
|
||
}
|
||
|
||
/// 打开 ⇒ 关闭:上一轮的选中与名字都要清掉(否则下次打开画的是上一次的草稿)。
|
||
#[test]
|
||
fn open_and_close_reset_the_form() {
|
||
let mut form = Form::default();
|
||
form.open();
|
||
assert!(form.open);
|
||
assert_eq!(form.busy, Busy::Scanning);
|
||
assert_eq!(form.target, AdoptTargetView::InstanceDir);
|
||
|
||
form.selected = Some("1.20.1".to_string());
|
||
form.name = "生存".to_string();
|
||
form.close();
|
||
assert!(!form.open);
|
||
assert!(form.selected.is_none());
|
||
assert!(form.name.is_empty());
|
||
|
||
// 再开一次也不该带着上一轮的选中项
|
||
form.open();
|
||
assert!(form.selected.is_none());
|
||
}
|
||
|
||
/// `settled` 只解除等待,不动用户填的东西(回程不该清表单)。
|
||
#[test]
|
||
fn settling_keeps_what_the_user_typed() {
|
||
let mut form = Form::default();
|
||
form.open();
|
||
form.selected = Some("1.20.1".to_string());
|
||
form.name = "生存".to_string();
|
||
form.settled();
|
||
assert_eq!(form.busy, Busy::Idle);
|
||
assert_eq!(form.selected.as_deref(), Some("1.20.1"));
|
||
assert_eq!(form.name, "生存");
|
||
}
|
||
|
||
/// 选中项不在候选列表里 ⇒ `candidate()` 是 `None`(界面不能对着一个不存在的版本画「预检」)。
|
||
#[test]
|
||
fn candidate_is_none_when_the_selection_vanished() {
|
||
let state = state_with_scan();
|
||
let mut form = Form {
|
||
selected: Some("1.20.1".to_string()),
|
||
..Form::default()
|
||
};
|
||
assert!(form.candidate(&state).is_some());
|
||
form.selected = Some("9.9.9".to_string());
|
||
assert!(form.candidate(&state).is_none());
|
||
}
|
||
|
||
/// 四段的标题与后果原文都从目录来(不写死英文字面量、不重写 core 的后果句)。
|
||
#[test]
|
||
fn the_four_stages_project_from_the_event_state() {
|
||
let catalog = Lang::ZhCn.catalog();
|
||
let mut state = state_with_scan();
|
||
|
||
// Pick
|
||
assert_eq!(state.stage(), AdoptStage::Pick);
|
||
assert_eq!(
|
||
shell::title_text(state.stage(), catalog),
|
||
catalog.text(Key::AdoptTitle),
|
||
"标题走目录"
|
||
);
|
||
assert!(shell::subtitle(state.stage(), &state, catalog).contains("不复制"));
|
||
|
||
// Preview:`notes` 是 core `target_notes()` 的原文,界面一个字都不许改
|
||
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()],
|
||
})));
|
||
assert_eq!(state.stage(), AdoptStage::Preview);
|
||
assert_eq!(
|
||
state.plan.as_ref().map(|plan| plan.notes.first().cloned()),
|
||
Some(Some(note.clone())),
|
||
"后果原文必须原样留着"
|
||
);
|
||
|
||
// Failed:problems 逐条(不折叠)
|
||
state.apply(AdoptEvent::Failed(Box::new(AdoptFailureView {
|
||
code: "adopt_not_adoptable".to_string(),
|
||
message: "版本 1.19.4 不能登记".to_string(),
|
||
problems: vec![
|
||
AdoptProblemView {
|
||
code: "manifest_missing".to_string(),
|
||
message: "没有清单".to_string(),
|
||
},
|
||
AdoptProblemView {
|
||
code: "missing_parent".to_string(),
|
||
message: "缺父版本 1.20.1".to_string(),
|
||
},
|
||
],
|
||
})));
|
||
assert_eq!(state.stage(), AdoptStage::Failed);
|
||
assert_eq!(
|
||
state.failure.as_ref().map(|f| f.problems.len()),
|
||
Some(2),
|
||
"两条问题一条都不许吞"
|
||
);
|
||
assert_eq!(
|
||
shell::title_text(state.stage(), catalog),
|
||
catalog.text(Key::AdoptFailureTitle)
|
||
);
|
||
|
||
// Done
|
||
state.apply(AdoptEvent::Done(Box::new(AdoptDoneView {
|
||
instance_id: "id-1".to_string(),
|
||
name: "1.20.1".to_string(),
|
||
version_id: "1.20.1".to_string(),
|
||
isolation: AdoptIsolationView::Shared,
|
||
run_dir: std::path::PathBuf::from("/game"),
|
||
dir_created: false,
|
||
notes: vec![note],
|
||
})));
|
||
assert_eq!(state.stage(), AdoptStage::Done);
|
||
assert_eq!(
|
||
shell::title_text(state.stage(), catalog),
|
||
catalog.text(Key::AdoptDoneTitle)
|
||
);
|
||
}
|
||
|
||
/// 中英两套目录的向导文案都不是占位(`text()` 回落到 key 名就是这个用例要抓的假绿)。
|
||
#[test]
|
||
fn both_catalogs_have_the_wizard_wording() {
|
||
for lang in [Lang::En, Lang::ZhCn] {
|
||
let catalog = lang.catalog();
|
||
for key in [
|
||
Key::AdoptTitle,
|
||
Key::AdoptSubtitle,
|
||
Key::AdoptScanning,
|
||
Key::AdoptEmpty,
|
||
Key::AdoptSelectLabel,
|
||
Key::AdoptReady,
|
||
Key::AdoptTargetLabel,
|
||
Key::AdoptTargetInstanceDir,
|
||
Key::AdoptTargetShared,
|
||
Key::AdoptTargetVersionDir,
|
||
Key::AdoptNameLabel,
|
||
Key::AdoptPreview,
|
||
Key::AdoptConfirm,
|
||
Key::AdoptBack,
|
||
Key::AdoptClose,
|
||
Key::AdoptPlanTitle,
|
||
Key::AdoptPlanRunDir,
|
||
Key::AdoptPlanIsolation,
|
||
Key::AdoptFailureTitle,
|
||
Key::AdoptProblemCount,
|
||
Key::AdoptDoneTitle,
|
||
Key::AdoptDoneHint,
|
||
] {
|
||
let value = catalog.text(key);
|
||
assert!(!value.is_empty(), "{key:?} 在 {lang:?} 是空的");
|
||
assert_ne!(
|
||
value,
|
||
format!("{key:?}"),
|
||
"{key:?} 在 {lang:?} 没进目录(回落成了 key 名)"
|
||
);
|
||
}
|
||
}
|
||
// 共享档的短提示必须把「会写进别人的 .minecraft」说清楚(选之前就得知道)
|
||
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(), "过期的预检不许画");
|
||
}
|
||
|
||
/// **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());
|
||
}
|