Files
Unlucky-Launcher/crates/presenter/src/host/install_tests.rs
T
AskaEth 30b1ff6f48 feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停
用户实跑反馈两条,本轮各自定位到根因并修掉:

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 + 金表同步)。
2026-11-04 22:12:22 +08:00

219 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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}");
}