用户实跑反馈两条,本轮各自定位到根因并修掉:
1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
`AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
`AppCommand::CreateInstance`。
- 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
`AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
- 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
- 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。
2. 「点取消没反应」= 真 bug,两跳各断一半:
- 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
`InstallDone` 带代次回来,代次不符一律丢。
- 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
返回新的 `InstallErrorKind::Cancelled`。
- 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
「哪些留着、哪些一定没发生」说清(D21)。
实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。
文案全部进 i18n(中英,9 条新 key + 金表同步)。
219 lines
7.0 KiB
Rust
219 lines
7.0 KiB
Rust
//! host/install.rs 的单测(拆文件守 400 行上限)。
|
||
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
|
||
|
||
use super::{loader_label, run, supported, unsupported_reason, Job};
|
||
use crate::dto::{CreateRequest, LoaderKind};
|
||
use crate::host::env::HostEnv;
|
||
use crate::host::install_journal::Journal;
|
||
use crate::host::job::{channel, JobSender};
|
||
use crate::host::meter::Meter;
|
||
|
||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||
|
||
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
|
||
struct Fixture {
|
||
dir: PathBuf,
|
||
env: HostEnv,
|
||
tx: JobSender,
|
||
meter: Arc<Mutex<Meter>>,
|
||
cancel: Arc<AtomicBool>,
|
||
journal: Journal,
|
||
}
|
||
|
||
impl Fixture {
|
||
fn new(request: &CreateRequest) -> Self {
|
||
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||
let dir =
|
||
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let env = HostEnv::at_root(&dir);
|
||
let journal = Journal::open(&env);
|
||
Self {
|
||
dir,
|
||
env,
|
||
tx: channel().0,
|
||
meter: Arc::new(Mutex::new(Meter::new(request, 1))),
|
||
cancel: Arc::new(AtomicBool::new(false)),
|
||
journal,
|
||
}
|
||
}
|
||
|
||
fn job(&self) -> Job<'_> {
|
||
Job {
|
||
env: &self.env,
|
||
tx: &self.tx,
|
||
meter: &self.meter,
|
||
cancel: &self.cancel,
|
||
journal: &self.journal,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for Fixture {
|
||
fn drop(&mut self) {
|
||
std::fs::remove_dir_all(&self.dir).ok();
|
||
}
|
||
}
|
||
|
||
fn request(loader: LoaderKind, version: &str) -> CreateRequest {
|
||
CreateRequest {
|
||
version: version.to_string(),
|
||
loader,
|
||
loader_version: String::new(),
|
||
name: String::new(),
|
||
}
|
||
}
|
||
|
||
/// 一个「刚下完」的文件结局(记账用)。
|
||
fn downloaded(bytes: u64) -> FileOutcome {
|
||
FileOutcome {
|
||
role: FileRole::Library,
|
||
name: "lib.jar".to_string(),
|
||
dest: PathBuf::from("lib.jar"),
|
||
status: FileStatus::Downloaded,
|
||
bytes,
|
||
source: None,
|
||
failure: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
|
||
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
|
||
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
|
||
let request = request(LoaderKind::Forge, "1.20.1");
|
||
let mut meter = Meter::new(&request, 1);
|
||
|
||
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
|
||
meter.progress.files_total = 30;
|
||
meter.progress.bytes_total = 3_000_000;
|
||
for _ in 0..30 {
|
||
meter.note(&downloaded(100_000));
|
||
}
|
||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||
|
||
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
|
||
meter.expect(29, 10_000_000);
|
||
meter.expect(36, 4_000_000);
|
||
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
|
||
assert_eq!(
|
||
meter.progress.bytes_total, 17_000_000,
|
||
"字节是相加不是取最大"
|
||
);
|
||
assert!(
|
||
meter.progress.ratio() < 0.7,
|
||
"预记账后不能还停在满格:{}",
|
||
meter.progress.ratio()
|
||
);
|
||
|
||
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
|
||
for _ in 0..36 {
|
||
meter.note(&downloaded(110_000));
|
||
}
|
||
let ratio = meter.progress.ratio();
|
||
assert!(
|
||
(0.6..1.0).contains(&ratio),
|
||
"回落到中间态而不是满格:{ratio}"
|
||
);
|
||
assert!(
|
||
meter.progress.bytes_done < meter.progress.bytes_total,
|
||
"字节总量要覆盖已下载量:{}/{}",
|
||
meter.progress.bytes_done,
|
||
meter.progress.bytes_total
|
||
);
|
||
|
||
// 加载器库也下完 → 这时候才该是满格
|
||
for _ in 0..29 {
|
||
meter.note(&downloaded(340_000));
|
||
}
|
||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||
}
|
||
|
||
#[test]
|
||
fn loader_support_matrix_is_explicit() {
|
||
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
|
||
for kind in [
|
||
LoaderKind::Vanilla,
|
||
LoaderKind::Fabric,
|
||
LoaderKind::Quilt,
|
||
LoaderKind::Forge,
|
||
LoaderKind::NeoForge,
|
||
] {
|
||
assert!(supported(kind), "{} 应当受支持", kind.label());
|
||
}
|
||
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
|
||
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
|
||
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
|
||
let reason = unsupported_reason(kind);
|
||
assert!(reason.contains(kind.label()), "{reason}");
|
||
assert!(reason.contains("还没有接入"), "{reason}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn forge_install_is_no_longer_refused_at_the_door() {
|
||
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
|
||
assert!(supported(LoaderKind::Forge));
|
||
assert!(supported(LoaderKind::NeoForge));
|
||
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
|
||
assert_eq!(
|
||
loader_label(LoaderKind::NeoForge, "21.1.80"),
|
||
"NeoForge 21.1.80"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn meter_counts_files_and_skips_skipped_bytes() {
|
||
let request = CreateRequest {
|
||
version: "1.20.4".to_string(),
|
||
loader: LoaderKind::Fabric,
|
||
loader_version: "0.16.5".to_string(),
|
||
name: "生存".to_string(),
|
||
};
|
||
let mut meter = Meter::new(&request, 1);
|
||
assert_eq!(meter.progress.loader, "Fabric 0.16.5");
|
||
assert_eq!(meter.progress.instance, "生存");
|
||
meter.progress.files_total = 2;
|
||
|
||
let mut outcome = FileOutcome {
|
||
role: FileRole::Library,
|
||
name: "a.jar".to_string(),
|
||
dest: PathBuf::from("a.jar"),
|
||
status: FileStatus::Downloaded,
|
||
bytes: 100,
|
||
source: Some("bmclapi".to_string()),
|
||
failure: None,
|
||
};
|
||
meter.note(&outcome);
|
||
assert_eq!(meter.progress.files_done, 1);
|
||
assert_eq!(meter.progress.bytes_done, 100);
|
||
|
||
outcome.status = FileStatus::Skipped;
|
||
outcome.bytes = 500;
|
||
meter.note(&outcome);
|
||
assert_eq!(meter.progress.files_done, 2);
|
||
assert_eq!(meter.progress.bytes_done, 100, "跳过的文件不计入已下载字节");
|
||
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_version_is_rejected_before_network() {
|
||
let request = request(LoaderKind::Vanilla, "");
|
||
let fixture = Fixture::new(&request);
|
||
let err = run(&fixture.job(), &request).unwrap_err();
|
||
assert!(err.contains("没有选择版本"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_loader_is_rejected_before_network() {
|
||
let request = request(LoaderKind::OptiFine, "1.20.1");
|
||
let fixture = Fixture::new(&request);
|
||
let err = run(&fixture.job(), &request).unwrap_err();
|
||
assert!(err.contains("还没有接入"), "{err}");
|
||
}
|