- `core/src/setting/keys.rs` 396(原 400 → 我的 +15 到 415):`download.proxy_mode` / `proxy_url` / `gh_proxy` 三键搬进新 `keys/proxy.rs`(`specs()` + 一句「空串 ≠ 关」的守卫), 主表留一行 `.with_all(proxy::specs())`——与 `font` / `update` 同一种形状。 - `core/tests/download/engine_mock_test.rs` 407 → 364:404 那条用例整条搬进 `tests/download/engine_mock/not_found.rs`(`#[path]` 子模块,仍是 `engine_mock` 这个 target)。 - `pages/settings/java/downloads/tests.rs` 423 → 313:gh-proxy 两条守卫搬进 `downloads/tests/gh_proxy.rs`(`#[path = "tests/gh_proxy.rs"]`)。 纯搬移 + 一条新单测(`keys/proxy.rs` 的「空串 = 跟随内置默认,不是关」);行为不变。
314 lines
11 KiB
Rust
314 lines
11 KiB
Rust
//! 「下载一个 JDK」的作业与状态(M197):纯事件 → 快照的映射守卫。
|
||
//!
|
||
//! 从 `downloads.rs` 拆出来守 400 行硬线(与 `tests/runtime.rs` 同一种拆法:断言跟着它守的
|
||
//! 东西走,模块文件只留实现)。这些用例**不联网**:喂的是引擎会吐的那几种 `ProgressEvent`。
|
||
use super::{Event, Failure, Job, Phase, Report, Slot};
|
||
use unlucky_core::download::types::DownloadId;
|
||
use unlucky_core::download::{DownloadErrorKind, ProgressEvent};
|
||
use unlucky_core::game::java::JavaErrorKind;
|
||
|
||
/// 一条下载事件链 → 界面能看到「百分比 + 命中哪个源」。
|
||
///
|
||
/// 变异证明:把 `apply` 里 `ProgressEvent::Progress` 那一臂的 `job.downloaded = downloaded`
|
||
/// 删掉 ⇒ 本用例红(百分比恒为 0)。
|
||
#[test]
|
||
fn progress_events_fill_the_percent_and_the_source() {
|
||
let slot = Slot::default();
|
||
slot.seed_report(Report {
|
||
job: Some(Job::new(21)),
|
||
..Report::default()
|
||
});
|
||
let id = DownloadId::next();
|
||
slot.apply(ProgressEvent::Started {
|
||
id,
|
||
kind: unlucky_core::download::ResourceKind::JavaRuntime,
|
||
url: "https://example.invalid/jdk.zip".to_string(),
|
||
dest: "jdk.zip".to_string(),
|
||
candidates: 2,
|
||
total_bytes: Some(200),
|
||
});
|
||
slot.apply(ProgressEvent::Progress {
|
||
id,
|
||
downloaded: 50,
|
||
total_bytes: Some(200),
|
||
});
|
||
let job = slot.report().job.expect("作业还在");
|
||
assert_eq!(job.phase, Phase::Downloading);
|
||
assert_eq!(job.percent(), Some(25), "50/200 = 25%");
|
||
slot.apply(ProgressEvent::Completed {
|
||
id,
|
||
bytes: 200,
|
||
from_cache: false,
|
||
source: "tuna".to_string(),
|
||
elapsed_ms: 10,
|
||
});
|
||
let job = slot.report().job.expect("作业还在");
|
||
assert_eq!(job.phase, Phase::Extracting, "下完就该进解压阶段");
|
||
assert_eq!(job.source.as_deref(), Some("tuna"), "命中哪个源要说得出");
|
||
}
|
||
|
||
/// 分母未知时**不做假进度**(`percent()` 给 `None`)。
|
||
#[test]
|
||
fn an_unknown_total_shows_no_percent() {
|
||
let slot = Slot::default();
|
||
slot.seed_report(Report {
|
||
job: Some(Job::new(17)),
|
||
..Report::default()
|
||
});
|
||
slot.apply(ProgressEvent::Progress {
|
||
id: DownloadId::next(),
|
||
downloaded: 1024,
|
||
total_bytes: None,
|
||
});
|
||
assert_eq!(slot.report().job.expect("作业").percent(), None);
|
||
}
|
||
|
||
/// 退避与换源要留在快照里(界面那一行「A 失败;2 秒后重试第 2 次」/「换源」)。
|
||
///
|
||
/// 变异证明:把 `SourceFailed` 那一臂的 `job.last = Some(Event::Retry { .. })` 删掉
|
||
/// ⇒ 本用例红。
|
||
#[test]
|
||
fn retries_and_switches_are_kept_for_the_ui() {
|
||
let slot = Slot::default();
|
||
slot.seed_report(Report {
|
||
job: Some(Job::new(8)),
|
||
..Report::default()
|
||
});
|
||
let id = DownloadId::next();
|
||
slot.apply(ProgressEvent::SourceFailed {
|
||
id,
|
||
source: "tuna".to_string(),
|
||
kind: DownloadErrorKind::ServerError,
|
||
reason: "服务器 503".to_string(),
|
||
attempt: 1,
|
||
will_retry: true,
|
||
retry_in_ms: 2_000,
|
||
});
|
||
assert_eq!(
|
||
slot.report().job.expect("作业").last,
|
||
Some(Event::Retry {
|
||
source: "tuna".to_string(),
|
||
kind: DownloadErrorKind::ServerError,
|
||
attempt: 2,
|
||
retry_in_ms: 2_000,
|
||
})
|
||
);
|
||
slot.apply(ProgressEvent::SourceSwitched {
|
||
id,
|
||
from: "tuna".to_string(),
|
||
to: "ustc".to_string(),
|
||
});
|
||
let job = slot.report().job.expect("作业");
|
||
assert_eq!(job.source.as_deref(), Some("ustc"));
|
||
assert_eq!(
|
||
job.last,
|
||
Some(Event::Switched {
|
||
from: "tuna".to_string(),
|
||
to: "ustc".to_string(),
|
||
})
|
||
);
|
||
}
|
||
|
||
/// 「该推荐哪一档」:`java.auto_install` 的真实消费者(界面上那行「为当前实例推荐这一档」)。
|
||
///
|
||
/// 变异证明:把 `recommend` 开头 `if !auto_install { return None; }` 删掉 ⇒ 关掉那一档红;
|
||
/// 把 `satisfied` 判据写成恒 `false` ⇒ 「已满足就不推荐」那一档红。
|
||
#[test]
|
||
fn auto_install_decides_whether_a_major_is_recommended() {
|
||
use super::recommend;
|
||
use crate::pages::settings::java::context::Requirement;
|
||
use crate::pages::settings::java::row::RuntimeRow;
|
||
|
||
fn row(major: u32) -> RuntimeRow {
|
||
RuntimeRow {
|
||
binary: format!("C:/jdk-{major}/bin/java.exe"),
|
||
version: format!("openjdk version \"{major}.0.1\""),
|
||
major: Some(major),
|
||
vendor: Some("Temurin".to_string()),
|
||
source: "system",
|
||
}
|
||
}
|
||
|
||
let required = Requirement {
|
||
instance: "生存服".to_string(),
|
||
version: "1.20.5".to_string(),
|
||
minimum: 21,
|
||
maximum: None,
|
||
};
|
||
let none: &[RuntimeRow] = &[];
|
||
assert_eq!(recommend(Some(&required), none, true), Some(21));
|
||
assert_eq!(recommend(Some(&required), none, false), None);
|
||
let satisfied = [row(21)];
|
||
assert_eq!(recommend(Some(&required), &satisfied, true), None);
|
||
let too_old = [row(17)];
|
||
assert_eq!(
|
||
recommend(Some(&required), &too_old, true),
|
||
Some(21),
|
||
"手里只有 17、实例要 21 时照样推荐 21"
|
||
);
|
||
assert_eq!(
|
||
recommend(None, none, true),
|
||
Some(17),
|
||
"没有实例时推荐基准 17"
|
||
);
|
||
assert_eq!(recommend(None, &satisfied, true), None);
|
||
}
|
||
|
||
/// 真下载一次(`--ignored`,需要网络):装到临时目录并确认 `bin/java` 存在。
|
||
///
|
||
/// 这是「真入口」的端到端证据:`Slot::start` → `engine_with`(镜像表 / 地区 / 健康度)→
|
||
/// `java::install`(查版本 → 回退链 → 下载 → 解压 → 探测)。默认不跑:它会下 ~180 MB。
|
||
#[test]
|
||
#[ignore = "真下载(网络 + 数百 MB),用 cargo test -- --ignored 手动跑"]
|
||
fn the_real_download_installs_a_usable_jdk() {
|
||
use super::{Request, Slot};
|
||
use unlucky_core::game::java::JavaInstallRoot;
|
||
|
||
let dir = std::env::temp_dir().join(format!("ull-java-install-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(dir.join("cache")).expect("temp");
|
||
let slot = Slot::default();
|
||
assert!(slot.start(Request {
|
||
major: 21,
|
||
cache_root: dir.join("cache"),
|
||
install_root: JavaInstallRoot::custom(dir.join("jdks")),
|
||
priority: Vec::new(),
|
||
gh_proxy: super::GhProxy::Builtin,
|
||
}));
|
||
// 失败/panic 也要清理:这条用例会真下 ~180 MB,留在系统 temp 里是最贵的垃圾。
|
||
struct Cleanup(std::path::PathBuf);
|
||
impl Drop for Cleanup {
|
||
fn drop(&mut self) {
|
||
let _ = std::fs::remove_dir_all(&self.0);
|
||
}
|
||
}
|
||
let _cleanup = Cleanup(dir.clone());
|
||
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(900);
|
||
while slot.busy() && std::time::Instant::now() < deadline {
|
||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||
}
|
||
assert!(!slot.busy(), "作业应在 15 分钟内结束");
|
||
let report = slot.report();
|
||
if let Some(failure) = &report.failure {
|
||
panic!("安装失败:{failure:?}");
|
||
}
|
||
let success = report.success.expect("应成功");
|
||
assert!(
|
||
std::path::Path::new(&success.binary).is_file(),
|
||
"bin/java 应存在:{}",
|
||
success.binary
|
||
);
|
||
}
|
||
|
||
/// 失败要留档:分类 + 大版本 + 最后试到的源(界面靠这三样说清「为什么没装成」)。
|
||
///
|
||
/// 变异证明:把 `finish` 的 `Err` 分支里 `report.failure = Some(...)` 删掉 ⇒ 本用例红。
|
||
#[test]
|
||
fn a_failure_is_kept_with_its_kind_and_source() {
|
||
let slot = Slot::default();
|
||
slot.seed_report(Report {
|
||
job: Some(Job {
|
||
source: Some("tuna".to_string()),
|
||
..Job::new(21)
|
||
}),
|
||
..Report::default()
|
||
});
|
||
slot.finish(
|
||
Err(unlucky_core::game::java::JavaError::new(
|
||
JavaErrorKind::Metadata,
|
||
"没有装成".to_string(),
|
||
)),
|
||
21,
|
||
);
|
||
let report = slot.report();
|
||
assert!(report.success.is_none());
|
||
assert!(report.job.is_none(), "失败之后不该还挂着作业");
|
||
let failure: Failure = report.failure.expect("失败要留档");
|
||
assert_eq!(failure.kind, JavaErrorKind::Metadata);
|
||
assert_eq!(failure.major, 21);
|
||
assert_eq!(failure.source.as_deref(), Some("tuna"));
|
||
}
|
||
|
||
/// **占位符不许漏**:`Catalog::render` 缺参数时会把 `{name}` 留下 `(name)`,而那张图已经能看见
|
||
/// (M197 出图时实测到 `试过 (tried) 家发行版`——模板里有一个调用点没给的占位符)。
|
||
///
|
||
/// 这条对**本页自己渲染的每一条下载文案**做同一件事:拿真实参数渲一遍,断言输出里没有
|
||
/// 「`(` + 标识符 + `)`」这种残留。ASCII 括号是判据:本页这些模板里没有合法的 ASCII 括号短语
|
||
/// (中文文案用全角「()」),所以命中即漏参。
|
||
///
|
||
/// 变异证明:把 `SettingsJavaDownloadFailed` 的模板改回「`(tried {n} vendors…)`」那种带
|
||
/// `{tried}` 的写法 ⇒ 本用例红(渲出 `(tried)`)。M197 就是这么发现它的。
|
||
#[test]
|
||
fn every_download_template_renders_without_leftover_placeholders() {
|
||
use unlucky_i18n::{Key, Lang};
|
||
|
||
let catalog = Lang::En.catalog();
|
||
/// 一条用例:标签 / key / 渲染参数。
|
||
type Case<'a> = (&'a str, Key, &'a [(&'a str, &'a str)]);
|
||
|
||
let cases: [Case<'_>; 6] = [
|
||
(
|
||
"failed",
|
||
Key::SettingsJavaDownloadFailed,
|
||
&[("major", "21"), ("source", "tuna")],
|
||
),
|
||
(
|
||
"failed-no-source",
|
||
Key::SettingsJavaDownloadFailedNoSource,
|
||
&[("major", "21")],
|
||
),
|
||
(
|
||
"done",
|
||
Key::SettingsJavaDownloadDone,
|
||
&[
|
||
("major", "21"),
|
||
("vendor", "Eclipse Temurin"),
|
||
("root", "D:/jdks/21"),
|
||
("source", "tuna"),
|
||
],
|
||
),
|
||
(
|
||
"done-reused",
|
||
Key::SettingsJavaDownloadDoneReused,
|
||
&[
|
||
("major", "21"),
|
||
("vendor", "Eclipse Temurin"),
|
||
("root", "D:/jdks/21"),
|
||
],
|
||
),
|
||
("busy", Key::SettingsJavaDownloadBusy, &[("major", "21")]),
|
||
(
|
||
"retry",
|
||
Key::SettingsJavaDownloadRetry,
|
||
&[
|
||
("source", "tuna"),
|
||
("reason", "server error"),
|
||
("attempt", "2"),
|
||
("seconds", "2.0"),
|
||
],
|
||
),
|
||
];
|
||
for (tag, key, args) in cases {
|
||
let text = catalog.render(key, args);
|
||
let mut rest = text.as_str();
|
||
while let Some(open) = rest.find('(') {
|
||
let after = &rest[open + 1..];
|
||
let close = after.find(')');
|
||
if let Some(close) = close {
|
||
let inner = &after[..close];
|
||
assert!(
|
||
inner.is_empty()
|
||
|| !inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||
"{tag} 渲染后还留着占位符 `({inner})`:{text}"
|
||
);
|
||
}
|
||
rest = after;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `download.gh_proxy` 的两条守卫(M197):取值映射 + 真消费者。拆到子模块守 400 行硬线。
|
||
#[path = "tests/gh_proxy.rs"]
|
||
mod gh_proxy;
|